Compare commits
1401 Commits
9545ab0789
...
test
Author | SHA1 | Date | |
---|---|---|---|
59ca353b25 | |||
6bc65ec412 | |||
97e95b51ab | |||
a6dfa81ba6 | |||
dad517a953 | |||
eb2d093b02 | |||
67186bba55 | |||
edeecad2ce | |||
387f5388d9 | |||
adcaa0a5fd | |||
47b2c1cb93 | |||
7f3589dcfb | |||
b134c28c10 | |||
41c8d0367d | |||
3b148d549e | |||
b6c96af8a2 | |||
4904625488 | |||
0574f4f629 | |||
4adc3e127c | |||
dd0a1c2293 | |||
a07407417c | |||
e33e3b43b7 | |||
634bf759ca | |||
0ed29c6097 | |||
b752434fbb | |||
eec63cc7b2 | |||
3dc9dd1f35 | |||
88e287067b | |||
27a3f450ef | |||
58a46a09c3 | |||
83a1316a64 | |||
f05f146c89 | |||
3782062f4a | |||
fd83abb46c | |||
a9d1b9f4a6 | |||
ad69dad725 | |||
2f55303d16 | |||
3a9128a894 | |||
def6296d4d | |||
034472defa | |||
550e4ac9ce | |||
d26e0a89f6 | |||
6767afdd35 | |||
a58de0cf92 | |||
df93f0e0ce | |||
0b54b126db | |||
a94cf8dad9 | |||
2c3e12a42c | |||
c4dbdc1b8e | |||
42ed4692af | |||
258943535c | |||
0347d767f0 | |||
48b0190242 | |||
15d0952de8 | |||
84ebc1762b | |||
a096b16945 | |||
37ac52116a | |||
fcb68be006 | |||
048c48d754 | |||
6ecac8d331 | |||
8b1dd7cb95 | |||
5a58fe9077 | |||
12574dbe46 | |||
b3e7c00232 | |||
692e060f6d | |||
2ac0a5f896 | |||
f8be99547a | |||
7dd585c3dd | |||
7355949c1e | |||
539b9fb2b2 | |||
99386c6d53 | |||
abbd73ac00 | |||
4bee95c8a6 | |||
090fc81829 | |||
75100cacec | |||
13fd262c94 | |||
8451cdfb80 | |||
c8841856c0 | |||
2a30b28e43 | |||
dd6849b840 | |||
ca27903e45 | |||
aeab6eddc2 | |||
1c0d40aed9 | |||
1444afaae2 | |||
a05bc369b7 | |||
6c7f411869 | |||
f61c45e89a | |||
27ed9f61d0 | |||
df77e31043 | |||
2d65bdb8ee | |||
4966aaeda9 | |||
28bd700b03 | |||
f2ca013b96 | |||
6cf7dabaef | |||
e6d63592ec | |||
3ac4ebded3 | |||
6f9fc659f3 | |||
005bb0ea2e | |||
80a0543e10 | |||
5d42805514 | |||
1b7ae8a2c5 | |||
168b0b13fb | |||
d99fcba468 | |||
147b8b0a42 | |||
eed755fd11 | |||
74a612704e | |||
8defc56d1e | |||
1db20d118d | |||
7a70a770bb | |||
cc9e4f974f | |||
2965b8fea0 | |||
00c617ec2e | |||
01ef738d31 | |||
423cbe7315 | |||
afb003c397 | |||
2dc5a29220 | |||
c525ec0330 | |||
735f1e26df | |||
5129400a29 | |||
a6a01aaa37 | |||
b819df9656 | |||
5d1c5fcc44 | |||
ebad3b31b7 | |||
3e9f7f9e29 | |||
4b3463e97c | |||
002f2c2834 | |||
1509ee0729 | |||
830e41dfa3 | |||
4d1f84cc5c | |||
1bafbed17c | |||
694d9cd05a | |||
60172ae84d | |||
7e7a1122fa | |||
a1533c8e98 | |||
b0a6fc6498 | |||
74ed7b20ba | |||
206c25985a | |||
0001697274 | |||
add21c45c5 | |||
ef8458c7a3 | |||
81f972edc1 | |||
c729a402aa | |||
2335050834 | |||
6340ed27cf | |||
618f80fddc | |||
45b6c8db96 | |||
5132a6b9fa | |||
de6642b675 | |||
3b42399726 | |||
689f9fe48f | |||
73038222cc | |||
2659adb7a9 | |||
fcb2ca1917 | |||
804e139385 | |||
f0fc996426 | |||
efdb485a3b | |||
3d695069a2 | |||
e068b57062 | |||
811810cd36 | |||
c90df4b02b | |||
7c1082f833 | |||
800b8d3216 | |||
ab877beae1 | |||
046c163e6f | |||
8e877a6366 | |||
d18c19dd35 | |||
a99260209b | |||
2192ddc8fa | |||
741a1282a3 | |||
1a6a331ad8 | |||
1ba63e2cab | |||
5696240e03 | |||
885243a5b0 | |||
a849d00c7f | |||
d04b44c931 | |||
a3aad9d2c9 | |||
d98268f809 | |||
34440e9ba3 | |||
d1c889e5f2 | |||
55da259510 | |||
4436e6f20a | |||
3cedd36e15 | |||
ecbe9b2e93 | |||
9ad6b6ea48 | |||
0d2daf4d2c | |||
edf16a6021 | |||
7551a19b34 | |||
f59f45d9a4 | |||
81e82ad731 | |||
ca870392e2 | |||
a7e167a95f | |||
a49b82a7c2 | |||
704ad12ccf | |||
ab9fd2bc16 | |||
69a63a77d3 | |||
da7e4c2156 | |||
a4b5185f6b | |||
22fc8b22b8 | |||
a8da17162a | |||
f13c221fd6 | |||
4ffa9363a8 | |||
6d2f48f86d | |||
8e01ced1f5 | |||
640f5ce6f5 | |||
c0be30027c | |||
832586bd41 | |||
1a774937b3 | |||
e508dafb34 | |||
8335717741 | |||
16a2b82ffd | |||
8db5c6443d | |||
9ed717fb95 | |||
dcd4497315 | |||
54c0322398 | |||
e3c33c71a0 | |||
7055bb9872 | |||
fd1b17e356 | |||
28427a873a | |||
5bdb101b52 | |||
97b2b38f8e | |||
2268f4a3fc | |||
9eff828249 | |||
3275ac5036 | |||
e049e0fa3c | |||
caee89cf53 | |||
e67b798714 | |||
dc13053825 | |||
af352256e9 | |||
b92810efd2 | |||
fcbd809691 | |||
d3ec13e6c0 | |||
a36d9f02d8 | |||
d6db862c9d | |||
56542a7bf1 | |||
36b8e8169e | |||
b102241efd | |||
f36010fefa | |||
aa23d6d50f | |||
6df043dfac | |||
fe84292483 | |||
0f48c71837 | |||
107e8fce55 | |||
3079998a5d | |||
e2d0ae558a | |||
1bca1b27ed | |||
6fc372c898 | |||
ddcd54d3b9 | |||
eb8c8c14e8 | |||
affc0cc235 | |||
f23251f5bb | |||
73c9a90ae3 | |||
ced35af66d | |||
b915ace6ff | |||
2fd7419bdd | |||
fd510710d9 | |||
8a924bd5be | |||
73edc0515f | |||
7870f8ea78 | |||
27c5b991cf | |||
8a937f01a4 | |||
3940282ed8 | |||
ca704f38b9 | |||
6ff044e4ab | |||
fa98138541 | |||
cb7917dc26 | |||
58d066af0a | |||
e2daff6463 | |||
7c3b7cffc2 | |||
775391f590 | |||
57adfec490 | |||
24e62c1885 | |||
a70b5d89ec | |||
761d56f4bd | |||
e759f62b5f | |||
9e2d031b5d | |||
b9cb8ad4a8 | |||
c1d4c1ff1d | |||
971683a81e | |||
51dae0f02c | |||
e2c70de2e0 | |||
d94418067f | |||
1cb2ee77b5 | |||
336d3c9434 | |||
7649ce6e52 | |||
5759a51017 | |||
dd5c121f1f | |||
cae3a92a66 | |||
562550880c | |||
a9c68f9971 | |||
d822a4a8ac | |||
e52c914000 | |||
a301f854ba | |||
602d9625e2 | |||
5598bca8d3 | |||
1bbaf8f7b7 | |||
3bb2753607 | |||
08848c783d | |||
6e229af790 | |||
ce8cc3eb29 | |||
198ecddc89 | |||
ae439b7e64 | |||
3f1101ff73 | |||
5777d9700f | |||
e1e9f4588a | |||
be2f013b9a | |||
0b03ebeb70 | |||
c466ecb77c | |||
ba9c71a4ec | |||
e33050a6d6 | |||
3595c02e74 | |||
3ff84074bd | |||
6dd6be183b | |||
0764247447 | |||
f9f9b9aab9 | |||
ec0252bae0 | |||
dc74d203bd | |||
387d364861 | |||
82afdecf6c | |||
519c63a023 | |||
d45a25258e | |||
bc822355df | |||
9535ff18de | |||
da0a83bb6d | |||
4977ee99df | |||
9ed031e574 | |||
b1fb62dd65 | |||
b7b166c362 | |||
46321dd3c1 | |||
1998a95c35 | |||
13a1fa674b | |||
e488f3419e | |||
dc1c29b69d | |||
c7eae53b22 | |||
b3b3d46696 | |||
537ec88d05 | |||
d54f05fa00 | |||
5708f4f059 | |||
353807404a | |||
81fa445964 | |||
f65ddbc5b8 | |||
b817a230fd | |||
3a180d478c | |||
74fecddf95 | |||
1dec8913c5 | |||
b9063fb22f | |||
287d133080 | |||
3ef1a732e5 | |||
7cd95da83c | |||
dd138bff86 | |||
327b0149d9 | |||
b822cf47bb | |||
30e1e461e3 | |||
3e25accaa3 | |||
5b3c5731ee | |||
84de4e0c5a | |||
48677a5a24 | |||
b0349ac133 | |||
925c5203be | |||
89a8a145df | |||
83a938dc53 | |||
75940bbb23 | |||
37516a0072 | |||
0f68b297a0 | |||
d454acdcbe | |||
2ce13afc0a | |||
2dd75ae7e8 | |||
a17a6a41da | |||
5db181aa74 | |||
d74f1ddb81 | |||
be12148d04 | |||
72d10f9443 | |||
81b11976a7 | |||
f918e89307 | |||
83ed4b6961 | |||
3216c73ee8 | |||
801b9934d6 | |||
7a745c2f4b | |||
4b1c2e36ed | |||
2e05b25c41 | |||
d4318cc48c | |||
780088eb0c | |||
2a3d7c9291 | |||
8ac9695535 | |||
5933a74885 | |||
afb64eb8f2 | |||
3c90f065fb | |||
8b731999a7 | |||
5182d03b16 | |||
433a9a29c5 | |||
3388bb4283 | |||
8ae225f434 | |||
c8c1087b73 | |||
feae2f5f98 | |||
2a96467d9c | |||
03bd915fa5 | |||
872ec7f13f | |||
7041aff350 | |||
000fb7c941 | |||
e0d978621b | |||
c29627bb64 | |||
839cbdeaec | |||
44e3eda145 | |||
00c705085e | |||
7b957c6732 | |||
03e1ef3271 | |||
4370fef5d5 | |||
93b0565368 | |||
e0565f7eed | |||
43ea4191c3 | |||
308127d044 | |||
ab2f581c9f | |||
51c4044e2f | |||
fbfb951825 | |||
b529d49e78 | |||
3344757af8 | |||
5521f39cc5 | |||
f9c34d14c3 | |||
dc0902c555 | |||
239516b98b | |||
3d1716d847 | |||
34452525d4 | |||
713d42a674 | |||
e1bfd944e9 | |||
a6f8f6a4d4 | |||
cf538a2c36 | |||
3caaa151f4 | |||
60ce64d3e1 | |||
9c9aa33687 | |||
a8589ef4e7 | |||
10dd50b332 | |||
bebfda0343 | |||
babfb27b1f | |||
258bd0796d | |||
2f0182e06c | |||
ecddf9975f | |||
664677a005 | |||
ca9e7da17e | |||
39eb3d48a8 | |||
a6e949bdd6 | |||
63f952d390 | |||
7e9cb556d0 | |||
dce1abaeff | |||
c4602369ae | |||
b7610641e5 | |||
8fb1247279 | |||
04f2ac6815 | |||
9be78062be | |||
3410045f83 | |||
14b0eeec7e | |||
d1579126f3 | |||
c5539bc7e3 | |||
0f8fcbcaed | |||
b1f82f9abe | |||
27deff3ff3 | |||
04eb416a73 | |||
05e714fff1 | |||
55badb6206 | |||
1b782f3df8 | |||
bbf3fc04b6 | |||
7657f779b5 | |||
3e4bfef14e | |||
09df1eb896 | |||
23b3e6cdce | |||
32a71664a4 | |||
ce881506f9 | |||
96b832983a | |||
8f2ec7f4dd | |||
705459ee90 | |||
155ea5c5e4 | |||
8904ef2247 | |||
de07b3d7de | |||
faf827de71 | |||
d95f95899c | |||
e9e538168c | |||
49a5e47f9d | |||
8285589b10 | |||
00ce6d6a7a | |||
d36aada227 | |||
5be86bf7d6 | |||
ddb49f6215 | |||
40c0b72450 | |||
e0c9a2cea7 | |||
df3f045209 | |||
6ccdfab551 | |||
24dc521f83 | |||
cdf96f4f6a | |||
807de3db57 | |||
e3e4151187 | |||
b34459e6c6 | |||
dd5e6c399b | |||
456372c7fb | |||
b4cd489ee9 | |||
b04f35c2da | |||
a26bb19b0f | |||
6182a7a77e | |||
d090631d1c | |||
c4d9d503ac | |||
824cd2f3ea | |||
47dfaec226 | |||
eb36313c9b | |||
354fbf7e29 | |||
1ddd40948e | |||
64d9f3e362 | |||
460196dc4d | |||
80841fe543 | |||
c8f96a10f0 | |||
b10c102f94 | |||
82b109e3bd | |||
cd0c066978 | |||
7a395a9906 | |||
96f571e0c4 | |||
8385800e48 | |||
9315447618 | |||
00c306475c | |||
affbb3eba3 | |||
ddd552deb4 | |||
b56b2e15af | |||
f77b5f67d0 | |||
bb41a81eb1 | |||
44dfa45ca8 | |||
8cfe9ade9a | |||
6e6b27bb65 | |||
df3a00f8c0 | |||
2e66b5fa45 | |||
1dba0a3d95 | |||
c9e90974bd | |||
4f0a882b9e | |||
a35b602f1a | |||
a3e717f2f7 | |||
8b10e0e770 | |||
22c302efa0 | |||
86450533cf | |||
d940b3092f | |||
99fdf473ae | |||
bb3263dd68 | |||
e29e71b8bd | |||
0c4dc7e5df | |||
36052f034a | |||
00e4fefc8f | |||
a1f9b676b5 | |||
330e4945e1 | |||
0583a8a56f | |||
bf62482137 | |||
ba17095536 | |||
4ff5e9e163 | |||
8a03249759 | |||
72cb90357e | |||
72563e9bfa | |||
f503492bf9 | |||
c7513e9045 | |||
368c647151 | |||
1ca676ce0b | |||
b33945d21c | |||
1649c08356 | |||
c54105e65b | |||
9039a7a2d0 | |||
a1ef9a4970 | |||
c1748001d5 | |||
e470e70612 | |||
e0d48712ac | |||
05592f94b9 | |||
4097e5a133 | |||
3093d2159d | |||
d6e5a45be9 | |||
10a65294ce | |||
22c5c5be25 | |||
7f4de67d67 | |||
559df6c7b8 | |||
b55e08a719 | |||
cc72e44fca | |||
84804d32ad | |||
fcae1b6770 | |||
b7d7afb8a5 | |||
e38ed331b6 | |||
2ba798b606 | |||
ee0c99bec9 | |||
e7232db2f3 | |||
4dc0a13203 | |||
2f2437e14d | |||
695ccf975b | |||
2d0492cafa | |||
68472b234e | |||
157e3a39b6 | |||
831bd731ca | |||
354ae68dd1 | |||
234a46d2ac | |||
5c7bf8086c | |||
dd614c07e2 | |||
f134bc4599 | |||
19dc676c36 | |||
adf181f790 | |||
82b07897f8 | |||
f9dd3bc7e2 | |||
7cec01897f | |||
297f6555f3 | |||
6111409d66 | |||
5820117c1a | |||
cc695c115b | |||
86dac7e2b4 | |||
52ddefa631 | |||
c46d6621ec | |||
d94ef1eb25 | |||
eee59855cc | |||
efdf1d3eed | |||
65b28f92d5 | |||
a5845c90c2 | |||
8ea51774e6 | |||
baeea79e66 | |||
85f14edc0a | |||
116aea3431 | |||
b8299bc139 | |||
7f1fadf068 | |||
9fcd172581 | |||
9425889e93 | |||
4c9277f61a | |||
91a8aa0afe | |||
fa99c296a5 | |||
dcf0637420 | |||
98b337c5ee | |||
ba0151bca0 | |||
c8c081b3fd | |||
078e601100 | |||
4eb4c19386 | |||
656fd9a1fa | |||
7f70e508e4 | |||
ebb9e5f4ae | |||
adfad4ad40 | |||
6409078c03 | |||
10b2bd1480 | |||
79e3d59a9a | |||
cda42c26e8 | |||
170cf9f217 | |||
43662738fc | |||
057ff17240 | |||
b9ad3bdb72 | |||
fc8b944031 | |||
5bfcbe9126 | |||
0d6e4804f5 | |||
d024e0fa23 | |||
f813d8eae4 | |||
e0424b1678 | |||
afc7bd68c1 | |||
d70a70b19c | |||
f1611efe7c | |||
9f2b43ea0c | |||
35e5e518f5 | |||
afebc190f6 | |||
e1b142a044 | |||
7204acb2bb | |||
738f1ea9fe | |||
2a70a7824f | |||
9f848e1bdc | |||
0c153aeb6a | |||
2c4c19990a | |||
dd0b751a43 | |||
a25b7d5cc2 | |||
fd3d596d57 | |||
20938e7d43 | |||
9516ce1c0f | |||
10f484357c | |||
1051846c5b | |||
4c50143834 | |||
d5e6ea8677 | |||
edb77d7ad7 | |||
2f58bdb381 | |||
472cfdb45b | |||
d0a0b5ecbe | |||
5e01ece02b | |||
47748875c0 | |||
d804ad268a | |||
8e5b43a14e | |||
7f855bfc56 | |||
307eea3ea2 | |||
e8cb4c6ea2 | |||
da3175292b | |||
b70c9518eb | |||
1cca469577 | |||
dd229f15ac | |||
2bd1c99409 | |||
b45c762bbe | |||
a7b58089dd | |||
e8d16217b0 | |||
ebd82ee2c7 | |||
b0c7819b5a | |||
93410af224 | |||
45e8b0d155 | |||
c8d7bdb8b7 | |||
bb897fe965 | |||
1551e3231c | |||
3a1db0f595 | |||
ac7b91b7e8 | |||
aa682aa10a | |||
e373e6ab0f | |||
6dd2a3136f | |||
8b69458d75 | |||
50974d55c2 | |||
9e679bf787 | |||
80b91aa445 | |||
c2d7d12767 | |||
429dc2b848 | |||
ca4ea0e5ea | |||
2860deb90a | |||
4a264d90d4 | |||
766d9668c2 | |||
abb60f5743 | |||
77a9f1a13c | |||
bf01bb66e5 | |||
dbc5d6c31e | |||
586db3f008 | |||
331a1549da | |||
a9d74605af | |||
9bb1195fb6 | |||
27bf60b94f | |||
e3bacfb8cb | |||
c4e58890ea | |||
80a78a036e | |||
8700030707 | |||
0e0114a7d1 | |||
263ca3ac9a | |||
27fbfd49e2 | |||
d3483c8062 | |||
2c77143fc2 | |||
fded23b97d | |||
9d7bd8e9ab | |||
e87f19a8df | |||
6daaf22dc0 | |||
0ea985178e | |||
fc3b62ee37 | |||
e2fa126a67 | |||
be4a58d1c6 | |||
9396f70f85 | |||
7f5e138cf7 | |||
ffc146df06 | |||
b5f37cb54b | |||
6aaa4f8cf6 | |||
3dca59685e | |||
a529f05825 | |||
4cee0e3585 | |||
46da172806 | |||
b72a2884f6 | |||
26b8dcee1b | |||
0c0c4019aa | |||
78d13aee1a | |||
e41ec1c91c | |||
e3f65c8941 | |||
f0aa0bc021 | |||
8595af7173 | |||
04f757a08c | |||
cf08d0d490 | |||
808030100f | |||
aa7c088504 | |||
0040a52169 | |||
a7db9bed44 | |||
21bf0910c5 | |||
fcfcb9845f | |||
059d5260a9 | |||
37853bdedd | |||
87f22f45aa | |||
d241b4fa7a | |||
685a3998a2 | |||
dad72b904f | |||
c406d21674 | |||
b0988cca70 | |||
81e1f7f6b1 | |||
fc6916fc2d | |||
c63949992f | |||
39b27b2a17 | |||
ae4a790236 | |||
33130140fd | |||
f9a8b431e0 | |||
9012dd14e2 | |||
4a6330d016 | |||
985e5e2bea | |||
4f77818406 | |||
e6dec42a00 | |||
849fd9d984 | |||
9b5c0696b1 | |||
a3442b8f2f | |||
30793b75d5 | |||
0e3b4200d8 | |||
6f0f53a9de | |||
6b138246a9 | |||
82f49667a9 | |||
54072412f3 | |||
047446ba3a | |||
6d6e1dd9ea | |||
6cba58c301 | |||
a168d90dd3 | |||
f4d9fa69e4 | |||
1a89177ecc | |||
382de101dd | |||
cf03eae4ec | |||
b775781fd7 | |||
2acf723c86 | |||
bbb193a787 | |||
976aeb0f75 | |||
3b807543b7 | |||
0350f86322 | |||
007be6b1ff | |||
3ec0cf4fac | |||
bb8dda6da0 | |||
c599e2ee00 | |||
1b49e3837c | |||
86e15d4155 | |||
6db8013e34 | |||
b10af9d9f1 | |||
7b04803aa0 | |||
d3b9fd7d78 | |||
c077f7322d | |||
c95b3db6eb | |||
0d709742ed | |||
246136b1ad | |||
44ffe20e88 | |||
290c4600fa | |||
84007e1b72 | |||
9bc1c610ac | |||
613298bdea | |||
0c325185fb | |||
0acf98aef3 | |||
2847cffa76 | |||
05c293ed6d | |||
3f86862ae9 | |||
9a80979a42 | |||
091ed270b0 | |||
92c19092fe | |||
42e55c0617 | |||
2792caa387 | |||
a438aae9bc | |||
5f607e2b75 | |||
7333b5d755 | |||
1d6c74162e | |||
8f84483826 | |||
69b46d791f | |||
4512aed44a | |||
0bf2b1b4ae | |||
0185c09d55 | |||
02e6e77453 | |||
b849de00dc | |||
50f02892e3 | |||
2a9eaead83 | |||
3b008155e1 | |||
501fa36fad | |||
4089590bdf | |||
c40220f766 | |||
dd3c1f45c8 | |||
4e84678a3c | |||
2ca4204775 | |||
476e4e8eb1 | |||
205deea88a | |||
bfc78b0ef9 | |||
3a403e5192 | |||
aad4f8dbb5 | |||
8d185c274a | |||
3b59d6c546 | |||
d33ed42853 | |||
8e5ed5bbc1 | |||
f8b02e5964 | |||
67457967c0 | |||
b3b7ef90e8 | |||
aa4af439be | |||
b03d424e2f | |||
2dfaf4ebae | |||
ccf8b0220e | |||
6b7b3efcb1 | |||
b929b51525 | |||
327f7ad341 | |||
2d1f333095 | |||
17c5bade83 | |||
fbc90b69d9 | |||
d72055c7d3 | |||
2e9a187935 | |||
e3f0145264 | |||
34b5dcccfc | |||
4023476685 | |||
df690dae0e | |||
d17f76261e | |||
71dffc4c0b | |||
acb6eb237c | |||
663b50654d | |||
7c7f2e0f2c | |||
1c8f5ef7ac | |||
69331edabb | |||
7c1c4b907b | |||
788a121994 | |||
eb5710837b | |||
c3e18d658c | |||
b8d422f45c | |||
5732ecfbfa | |||
536923c00b | |||
28124fc059 | |||
48706f0bd5 | |||
e06b4c3179 | |||
e4d251a0b3 | |||
3d581b1677 | |||
400d281cf6 | |||
64c6cc05de | |||
6d40ef6f4d | |||
71937ce89c | |||
0d402b608c | |||
c66421b45d | |||
5609bdb6f4 | |||
bbfcf5fd4f | |||
97d44104e1 | |||
ae04b22b2a | |||
31e33d49df | |||
59a035e5c2 | |||
945fd5d5a3 | |||
157936acef | |||
72b48e136f | |||
2f08149e48 | |||
48d5f1674f | |||
ede4c465c6 | |||
0826db0a5b | |||
9f3a25bd7d | |||
348936a67e | |||
bcd094c5dd | |||
0e9863050f | |||
c097cb54f1 | |||
01aeb8e759 | |||
b163427514 | |||
7328283b6b | |||
4fb97a7c95 | |||
3e89025fd9 | |||
183f8098be | |||
6c9d57b18a | |||
eb552f01f0 | |||
95717824c6 | |||
f369650711 | |||
81a94082aa | |||
dd8634c47b | |||
f577e137c2 | |||
728b00abdb | |||
b6cdeee548 | |||
70255273ed | |||
5890f9932b | |||
88d326023b | |||
144bb4945b | |||
d621e271a0 | |||
1d904c5cde | |||
d32f503633 | |||
537cdbb307 | |||
18f53df9f8 | |||
dcd26308d3 | |||
0f1ed03caf | |||
c935cdd8ee | |||
c9bb3aa489 | |||
aea1f1889e | |||
3df0b1fcec | |||
113d29fab0 | |||
ebb969c039 | |||
9a30dd6de5 | |||
525f837e21 | |||
db0e526896 | |||
e76eed7274 | |||
286629836c | |||
354c8c3d4a | |||
d3ffa1d40a | |||
a6a279837d | |||
e3cf7fbfa0 | |||
7bafb6ba0d | |||
738d7fba2a | |||
0aaac97915 | |||
720ee63505 | |||
e1ea1f14a5 | |||
6efab40a85 | |||
22f274fd32 | |||
c50f24b755 | |||
b8b387c33d | |||
a2e6d09ee8 | |||
8be2ec9319 | |||
34c08d4345 | |||
dac5858541 | |||
11dcb399f6 | |||
653f1f8d58 | |||
bf634b09db | |||
89beba25b6 | |||
67522804d9 | |||
da1c096b45 | |||
4a27a825a1 | |||
77ed131f89 | |||
f70e5bae9a | |||
cec4cd0d28 | |||
95e31bb629 | |||
443818efb5 | |||
711842f00d | |||
3ea6b5824b | |||
be8f0d66b9 | |||
1ec7a6f096 | |||
8f44d0b2cd | |||
71a3c357e7 | |||
5ac6750ffe | |||
92bcbbe065 | |||
844f9fd79b | |||
d2ecca55b3 | |||
1b7ecc4afe | |||
abd1626997 | |||
afe529a116 | |||
3e8476431d | |||
2d015d0a33 | |||
765aec3620 | |||
8a866df5a3 | |||
0dd1a706fd | |||
f89a61e23e | |||
c479e5ad81 | |||
1a02f2383e | |||
319893d60f | |||
e007a95982 | |||
ca2cc7a6b6 | |||
76ade3daa1 | |||
41be05093d | |||
40f4a12f9b | |||
5e093a5555 | |||
06d670df50 | |||
9192209ca7 | |||
26d9b6cf35 | |||
4923a04c9d | |||
52a174d1b3 | |||
6101b964af | |||
a241b96d59 | |||
315c957d71 | |||
5a0bf61a36 | |||
3d76220660 | |||
02254c29e4 | |||
c65bad63ca | |||
538d4288bb | |||
75f3d1dab3 | |||
fbaa1aa14c | |||
fb66ea3347 | |||
123b21cab2 | |||
f45e07c879 | |||
d20f51ceac | |||
f80b8248e8 | |||
ba2530ba55 | |||
3b97364f24 | |||
209f1f4bd1 | |||
38cf9e453d | |||
f304242eb4 | |||
9f59578275 | |||
4a83ebd472 | |||
81415e0854 | |||
e6777962a9 | |||
8c5b7b811d | |||
7326babc49 | |||
555c3bfc73 | |||
e4ca84252d | |||
d1d9ef5d24 | |||
e48fcc9d25 | |||
024b8ce872 | |||
421846e08a | |||
2735ac32bb | |||
ee35a0c13e | |||
29c7d5c677 | |||
25022e4909 | |||
0df0d71660 | |||
7204233663 | |||
e090a2fe7a | |||
290be744a3 | |||
13dc77666a | |||
8a3d11ae59 | |||
7e02acd22c | |||
da4ecf7d23 | |||
185d7e6666 | |||
80930ebd3d | |||
13088f53a6 | |||
0c3f190044 | |||
35289ea93e | |||
63f690ee2d | |||
1a68746a7c | |||
6ab9871fb9 | |||
7badf857cb | |||
612ca02461 | |||
b5e6176885 | |||
6b90f42a0e | |||
cae9f22e49 | |||
aeed1dbd06 | |||
382364b5df | |||
0e8d3656b2 | |||
0d2e0a1af8 | |||
516853b05f | |||
e96b3d9bd4 | |||
1a5b4b364a | |||
f196b20024 | |||
11bb799bd5 | |||
84102bb95a | |||
3ada2dea87 | |||
82ac381936 | |||
3c72ae048e | |||
61cf1577dc | |||
7fefc9b0a6 | |||
5ce4078ba4 | |||
8718089483 | |||
5a75972f26 | |||
dcd6933824 | |||
35f66e7e41 | |||
8d32f1b3bd | |||
04314c6256 | |||
68c2b505bb | |||
9448c6a488 | |||
481f2e1126 | |||
9751900193 | |||
07455cd5b1 | |||
216f983d36 | |||
cb9ba824cf | |||
a215d027ec | |||
41a4802dc6 | |||
06a890bc15 | |||
d382d85d26 | |||
b464f7ae4c | |||
3164232f9d | |||
8da7ee0bb3 | |||
9458a3976b | |||
dbf795804c | |||
e3a31f16bc | |||
234c9adcdd | |||
b7e4e6f43f | |||
725b959117 | |||
19875a841d | |||
b3b7f739ff | |||
9c367b400a | |||
0b0be28e1a | |||
fc17ba9aaa | |||
31208b5e99 | |||
c590eff460 | |||
472b51dd85 | |||
ad295564de | |||
bfedc448af | |||
1a6a833b93 | |||
615164e488 | |||
8be8d15e4c | |||
60012a7aad | |||
75140a4055 | |||
6bc5143471 | |||
1eeea16792 | |||
e0e24be688 | |||
db3ac923e1 | |||
24fc451574 | |||
ee1c8d1f83 | |||
fc39b6c7a0 | |||
62797eb3f5 | |||
9afc44b7b1 | |||
1b7fc14f00 | |||
9a394b7dae | |||
ce15025c8d | |||
0e786f46cb | |||
5b218169c7 | |||
5c228af14b | |||
976a504233 | |||
58a57c9a0e | |||
1d877255ac | |||
97e80a85e0 | |||
cf8d94776e | |||
bc1db79430 | |||
42fbcdd1e8 | |||
dad31fffcb | |||
ed3e55514a | |||
275f6049d1 | |||
d3b12eeef1 | |||
b3d66151bc | |||
1cf70c25a9 | |||
695c8cbad8 | |||
8fb61e7689 | |||
d45a34525b | |||
c6c9073fa0 | |||
64a63fa8fa | |||
22b9dceea5 | |||
899bc076a3 | |||
8017e837ef | |||
4c8c6226a6 | |||
fc99e6324a | |||
146205e4f7 | |||
1c7fdfac69 | |||
3ec16b5045 | |||
5b85c2e8a4 | |||
063ee78a8c | |||
5f3c7e7e90 | |||
1f055c2283 | |||
4eec062871 | |||
e7a318d6b9 | |||
9a7e76ea7a | |||
f45c6c7938 | |||
c43faef14e | |||
487d3c9d6e | |||
04c682fc9b | |||
ae6171e844 | |||
fb236a374d | |||
1be91d0de4 | |||
cda2f1ec36 | |||
e5c85287bb | |||
1fcb0ec5fd | |||
3537f22197 | |||
99d7510c32 | |||
bcdd161205 | |||
0f6c3075bc | |||
cd4b165f90 | |||
567c51f6a2 | |||
0aecd36956 | |||
5068be1a4c | |||
7a22e7d887 | |||
6775d3d72d | |||
149a3ad2f1 | |||
9146e2e231 | |||
333458a184 | |||
f889ae5232 | |||
e507af8d5b | |||
cccb76afc2 | |||
d561ad6d41 | |||
a7fc89cf40 | |||
b59d7b5dca | |||
4f0148d80e | |||
ed4f0a62a1 | |||
51260311a0 | |||
75efb564fc | |||
f6e9c6d010 | |||
236aeb0258 | |||
90732e137f | |||
5b0be30c5b | |||
13aa9838cd | |||
24b8618306 | |||
75dbfad3a7 | |||
ffdcd61894 | |||
ba491420f5 | |||
f161d9a436 | |||
fd460f2d3e | |||
468add0819 | |||
151cc10caf | |||
22a79c0be4 | |||
5970a9a5b6 | |||
8896233d78 | |||
d0d6ef64df | |||
ff10a9935b | |||
d5cc28e50b | |||
615d4baef8 | |||
9578e54ea7 | |||
5ff288f739 | |||
f081b9691f | |||
06bb2ea228 | |||
81e2b43231 | |||
27c40da7b4 | |||
8b30c1c319 | |||
300e20dcd0 | |||
0978549675 | |||
ab4a2d0e6b | |||
74f7c75012 | |||
37f2f5e40b | |||
056e575e7c | |||
febfa442fa | |||
27f4d78f0d | |||
858f1a9a32 | |||
d68a089235 | |||
f3590dac7a | |||
aa457cc2fb | |||
7ca71f90db | |||
d2ae958847 | |||
a285e30108 | |||
896246d9ed | |||
d6dfa63bea | |||
8f50d05906 | |||
88520dbc7f | |||
6aec31711d | |||
1ba3cb6277 | |||
eece72bc40 | |||
41fe37bdb2 | |||
57426b5b5b | |||
42f5c49cbc | |||
378e8f13af | |||
6cf401539f | |||
13761a4130 | |||
fc2737921b | |||
4603ea4f96 | |||
bd09bfacd0 | |||
ed78959817 | |||
ad66be3449 | |||
00a6fa0c91 | |||
f25888c88e | |||
1629bc8bf7 | |||
29c9460362 | |||
7bb7e92137 | |||
cd833dc21d | |||
fb8309c7b4 | |||
779c3f824e | |||
2abfb8ce58 | |||
9046c10d10 | |||
a9d3427b6f | |||
2e249db5f5 | |||
f2ecf3a676 | |||
53bc8ed05a | |||
c5fd55a6f8 | |||
586f4d21d4 | |||
0d56b61b2e | |||
3b9d8a4fcb | |||
72adae3f54 | |||
d1ea7b8704 | |||
41a6e05034 | |||
0679fdfb6d | |||
118c5c2eed | |||
d865ec3ef8 | |||
7af970ace4 | |||
65e0b87d79 | |||
eab9ac5f05 | |||
d414e3b300 | |||
a47b40d922 | |||
9126671581 | |||
eaeca443cf | |||
f49203cafc | |||
307c09a4c1 | |||
c3cb8fe93b | |||
d0aebe906b | |||
fa8d8c4ab1 | |||
ff81c6c5ca | |||
4900b52d2b | |||
82a6161abf | |||
077af3a46d | |||
498d9c4893 | |||
2410d77cb7 | |||
40bdd5ba1c | |||
629528b6cf | |||
a8c3f05ffa | |||
2a24c376b3 | |||
73988d7b2c | |||
1d217b9f75 | |||
3ad2256a66 | |||
91d25081c0 | |||
dde4eb4a98 | |||
248e57b08c | |||
1884e5a5d9 | |||
409af8b18c | |||
7b58335a42 | |||
25be1a6adc | |||
9236aece18 | |||
c84a9bc473 | |||
ba69f86295 | |||
6f40838d09 | |||
4bf8617102 | |||
c8764be69f | |||
c9970ce7ca | |||
c876378b21 | |||
0fa16762bd | |||
e5531bbef7 | |||
ccb32a73dd | |||
2548c92aa1 | |||
b331e6e152 | |||
11f1f781a0 | |||
eb09251955 | |||
777f4755be | |||
ba3444fd26 | |||
e9723d37ba | |||
705bf0b6b2 | |||
771dbeced0 | |||
f7cdf40976 | |||
14220ff6dc | |||
b556219e36 | |||
6ff38decab | |||
872e84baf1 | |||
581b6975a3 | |||
b99ab9103d | |||
34590347a6 | |||
14b25bdfc3 | |||
7696f06fbd | |||
38f6e8d870 | |||
12c9b14168 | |||
dc299f7727 | |||
a983595bad | |||
87a5ceee9c | |||
3d514e8ad4 | |||
94551b05ff | |||
841e32a50b | |||
cbcc63dc71 | |||
9b4e2fd192 | |||
c9a9c8c310 | |||
fcd435f470 | |||
88df83fdc0 | |||
03d782850c | |||
a009a728a8 | |||
b12fba992c | |||
1fe5309fdc | |||
5d6eb5da4f | |||
472b8d36f5 | |||
25b3bcb534 | |||
980faae943 | |||
234e02dca4 | |||
d9f6ac01f4 | |||
baad5653e8 | |||
0c106540cd | |||
16c5c5f6b6 | |||
fff8037277 | |||
4a7e684606 | |||
bc0baeffe9 | |||
b3d72ead1f | |||
c25b105d4d | |||
049e1c41de | |||
df861bf8a1 | |||
7d904067fe | |||
018a4a95a2 | |||
3c09047371 | |||
5a56990d0b | |||
7671e24470 | |||
3cac42b5b9 | |||
fb1386be05 | |||
58a7f87ffd | |||
f393c7630e | |||
197cca1f1b |
@@ -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 = 120
|
max_line_length = 130
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,4 +323,7 @@ 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
|
||||||
|
@@ -26,11 +26,14 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
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("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
|
implementation("org.springframework.retry:spring-retry")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
|
|
||||||
// jwt
|
// jwt
|
||||||
@@ -44,6 +47,7 @@ dependencies {
|
|||||||
kapt("org.springframework.boot:spring-boot-configuration-processor")
|
kapt("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
|
|
||||||
// aws
|
// aws
|
||||||
|
implementation("com.amazonaws:aws-java-sdk-sqs:1.12.380")
|
||||||
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
|
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
|
||||||
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
|
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
|
||||||
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
|
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
|
||||||
@@ -53,6 +57,21 @@ dependencies {
|
|||||||
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.9.3")
|
implementation("com.squareup.okhttp3:okhttp:4.9.3")
|
||||||
implementation("org.json:json:20230227")
|
implementation("org.json:json:20230227")
|
||||||
|
implementation("com.google.code.findbugs:jsr305:3.0.2")
|
||||||
|
|
||||||
|
// firebase admin sdk
|
||||||
|
implementation("com.google.firebase:firebase-admin:9.2.0")
|
||||||
|
|
||||||
|
// android publisher
|
||||||
|
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.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||||
|
|
||||||
|
// file mimetype check
|
||||||
|
implementation("org.apache.tika:tika-core:3.2.0")
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
|
@@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.retry.annotation.EnableRetry
|
||||||
import org.springframework.scheduling.annotation.EnableAsync
|
import org.springframework.scheduling.annotation.EnableAsync
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableRetry
|
||||||
class SodaLiveApplication
|
class SodaLiveApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/audition")
|
||||||
|
class AdminAuditionController(private val service: AdminAuditionService) {
|
||||||
|
@PostMapping
|
||||||
|
fun createAudition(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.createAudition(image, requestString), "등록되었습니다.")
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun updateAudition(
|
||||||
|
@RequestPart("image", required = false) image: MultipartFile? = null,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.updateAudition(image, requestString), "수정되었습니다.")
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAuditionList(pageable: Pageable) = ApiResponse.ok(
|
||||||
|
service.getAuditionList(
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getAuditionDetail(@PathVariable id: Long) = ApiResponse.ok(
|
||||||
|
service.getAuditionDetail(auditionId = id)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,69 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.audition.Audition
|
||||||
|
import kr.co.vividnext.sodalive.audition.QAudition.audition
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface AdminAuditionRepository : JpaRepository<Audition, Long>, AdminAuditionQueryRepository
|
||||||
|
|
||||||
|
interface AdminAuditionQueryRepository {
|
||||||
|
fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem>
|
||||||
|
fun getAuditionListCount(): Int
|
||||||
|
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminAuditionQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val coverImageHost: String
|
||||||
|
) : AdminAuditionQueryRepository {
|
||||||
|
override fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionListItem(
|
||||||
|
audition.id,
|
||||||
|
audition.title,
|
||||||
|
audition.imagePath.prepend("/").prepend(coverImageHost),
|
||||||
|
audition.isAdult,
|
||||||
|
audition.information,
|
||||||
|
audition.status,
|
||||||
|
audition.originalWorkUrl.coalesce("")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audition)
|
||||||
|
.where(audition.isActive.isTrue)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy(audition.isActive.desc(), audition.id.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuditionListCount(): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(audition.id)
|
||||||
|
.from(audition)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionDetailRawData(
|
||||||
|
audition.id,
|
||||||
|
audition.title,
|
||||||
|
audition.imagePath.prepend("/").prepend(coverImageHost),
|
||||||
|
audition.information,
|
||||||
|
audition.originalWorkUrl.coalesce("")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audition)
|
||||||
|
.where(audition.id.eq(auditionId))
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminAuditionService(
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val repository: AdminAuditionRepository,
|
||||||
|
private val roleRepository: AdminAuditionRoleRepository,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val bucket: String
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createAudition(image: MultipartFile, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, CreateAuditionRequest::class.java)
|
||||||
|
val audition = repository.save(request.toAudition())
|
||||||
|
|
||||||
|
val fileName = generateFileName("audition")
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audition/production/${audition.id}/$fileName"
|
||||||
|
)
|
||||||
|
audition.imagePath = imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||||
|
val audition = repository.findByIdOrNull(id = request.id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
|
if (request.title != null) {
|
||||||
|
audition.title = request.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.information != null) {
|
||||||
|
audition.information = request.information
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isAdult != null) {
|
||||||
|
audition.isAdult = request.isAdult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status != null) {
|
||||||
|
if (
|
||||||
|
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||||
|
request.status == AuditionStatus.NOT_STARTED
|
||||||
|
) {
|
||||||
|
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
audition.status = request.status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.originalWorkUrl != null) {
|
||||||
|
audition.originalWorkUrl = request.originalWorkUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
val fileName = generateFileName("audition")
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audition/production/${audition.id}/$fileName"
|
||||||
|
)
|
||||||
|
audition.imagePath = imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
audition.isActive = request.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
FcmEvent(
|
||||||
|
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||||
|
title = "새로운 오디션 등록!",
|
||||||
|
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
||||||
|
isAuth = audition.isAdult,
|
||||||
|
auditionId = audition.id ?: -1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuditionList(offset: Long, limit: Long): GetAuditionListResponse {
|
||||||
|
val totalCount = repository.getAuditionListCount()
|
||||||
|
val items = repository.getAuditionList(offset = offset, limit = limit)
|
||||||
|
return GetAuditionListResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse {
|
||||||
|
val auditionDetail = repository.getAuditionDetail(auditionId = auditionId)
|
||||||
|
val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId)
|
||||||
|
|
||||||
|
return GetAuditionDetailResponse(
|
||||||
|
id = auditionDetail.id,
|
||||||
|
title = auditionDetail.title,
|
||||||
|
imageUrl = auditionDetail.imageUrl,
|
||||||
|
information = auditionDetail.information,
|
||||||
|
originalWorkUrl = auditionDetail.originalWorkUrl,
|
||||||
|
roleList = roleList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.Audition
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
|
||||||
|
data class CreateAuditionRequest(
|
||||||
|
val title: String,
|
||||||
|
val information: String,
|
||||||
|
val isAdult: Boolean = false,
|
||||||
|
val originalWorkUrl: String? = null
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
if (title.isBlank()) {
|
||||||
|
throw SodaException("오디션 제목을 입력하세요")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (information.isBlank() || information.length < 10) {
|
||||||
|
throw SodaException("오디션 정보는 최소 10글자 입니다")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAudition(): Audition {
|
||||||
|
return Audition(
|
||||||
|
title = title,
|
||||||
|
information = information,
|
||||||
|
isAdult = isAdult,
|
||||||
|
originalWorkUrl = originalWorkUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
|
|
||||||
|
data class GetAuditionDetailRawData @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val information: String,
|
||||||
|
val originalWorkUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAuditionDetailResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val information: String,
|
||||||
|
val originalWorkUrl: String,
|
||||||
|
val roleList: List<GetAuditionRoleListData>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAuditionRoleListData @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val information: String,
|
||||||
|
val auditionScriptUrl: String,
|
||||||
|
val status: AuditionStatus
|
||||||
|
)
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
|
|
||||||
|
data class GetAuditionListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetAuditionListItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAuditionListItem @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val information: String,
|
||||||
|
val status: AuditionStatus,
|
||||||
|
val originalWorkUrl: String
|
||||||
|
)
|
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
|
|
||||||
|
data class UpdateAuditionRequest(
|
||||||
|
val id: Long,
|
||||||
|
val title: String? = null,
|
||||||
|
val information: String? = null,
|
||||||
|
val isAdult: Boolean? = null,
|
||||||
|
val status: AuditionStatus? = null,
|
||||||
|
val originalWorkUrl: String? = null,
|
||||||
|
val isActive: Boolean? = null
|
||||||
|
)
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/audition/applicant")
|
||||||
|
class AdminAuditionApplicantController(private val service: AdminAuditionApplicantService) {
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun deleteAuditionApplicant(@PathVariable id: Long) = ApiResponse.ok(
|
||||||
|
service.deleteAuditionApplicant(id),
|
||||||
|
"오디션 지원이 취소 되었습니다."
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AdminAuditionApplicantRepository : JpaRepository<AuditionApplicant, Long>
|
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminAuditionApplicantService(private val repository: AdminAuditionApplicantRepository) {
|
||||||
|
@Transactional
|
||||||
|
fun deleteAuditionApplicant(id: Long) {
|
||||||
|
val applicant = repository.findByIdOrNull(id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
applicant.isActive = false
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,47 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/audition/role")
|
||||||
|
class AdminAuditionRoleController(private val service: AdminAuditionRoleService) {
|
||||||
|
@PostMapping
|
||||||
|
fun createAuditionRole(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.createAuditionRole(image, requestString), "등록되었습니다.")
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun updateAuditionRole(
|
||||||
|
@RequestPart("image", required = false) image: MultipartFile? = null,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.updateAuditionRole(image, requestString), "수정되었습니다.")
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getAuditionRoleDetail(@PathVariable id: Long) = ApiResponse.ok(
|
||||||
|
service.getAuditionRoleDetail(auditionRoleId = id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/{id}/applicant")
|
||||||
|
fun getAuditionApplicantList(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getAuditionApplicantList(
|
||||||
|
auditionRoleId = id,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,106 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.admin.audition.GetAuditionRoleListData
|
||||||
|
import kr.co.vividnext.sodalive.admin.audition.QGetAuditionRoleListData
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionRole
|
||||||
|
import kr.co.vividnext.sodalive.audition.QAudition.audition
|
||||||
|
import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant
|
||||||
|
import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole
|
||||||
|
import kr.co.vividnext.sodalive.audition.QAuditionVote.auditionVote
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AdminAuditionRoleRepository : JpaRepository<AuditionRole, Long>, AdminAuditionRoleQueryRepository
|
||||||
|
|
||||||
|
interface AdminAuditionRoleQueryRepository {
|
||||||
|
fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData>
|
||||||
|
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse
|
||||||
|
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): List<GetAuditionRoleApplicantItem>
|
||||||
|
fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminAuditionRoleQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudfrontHost: String
|
||||||
|
) : AdminAuditionRoleQueryRepository {
|
||||||
|
override fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionRoleListData(
|
||||||
|
auditionRole.id,
|
||||||
|
auditionRole.name,
|
||||||
|
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
|
||||||
|
auditionRole.information,
|
||||||
|
auditionRole.auditionScriptUrl,
|
||||||
|
auditionRole.status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(auditionRole)
|
||||||
|
.innerJoin(auditionRole.audition, audition)
|
||||||
|
.where(
|
||||||
|
auditionRole.audition.id.eq(auditionId),
|
||||||
|
auditionRole.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionRoleDetailResponse(
|
||||||
|
auditionRole.name,
|
||||||
|
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
|
||||||
|
auditionRole.information,
|
||||||
|
auditionRole.auditionScriptUrl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(auditionRole)
|
||||||
|
.where(auditionRole.id.eq(auditionRoleId))
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuditionApplicantList(
|
||||||
|
auditionRoleId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetAuditionRoleApplicantItem> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionRoleApplicantItem(
|
||||||
|
auditionApplicant.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage.prepend("/").prepend(cloudfrontHost),
|
||||||
|
auditionApplicant.phoneNumber,
|
||||||
|
auditionApplicant.voicePath.prepend("/").prepend(cloudfrontHost),
|
||||||
|
auditionVote.id.count()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(auditionApplicant)
|
||||||
|
.innerJoin(auditionApplicant.member, member)
|
||||||
|
.innerJoin(auditionApplicant.role, auditionRole)
|
||||||
|
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
||||||
|
.where(
|
||||||
|
auditionRole.id.eq(auditionRoleId),
|
||||||
|
auditionApplicant.isActive.isTrue
|
||||||
|
)
|
||||||
|
.groupBy(auditionApplicant.id)
|
||||||
|
.orderBy(auditionVote.id.count().desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(auditionApplicant.id)
|
||||||
|
.from(auditionApplicant)
|
||||||
|
.innerJoin(auditionApplicant.role, auditionRole)
|
||||||
|
.where(auditionRole.id.eq(auditionRoleId))
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.audition.AdminAuditionRepository
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionRole
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminAuditionRoleService(
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val repository: AdminAuditionRoleRepository,
|
||||||
|
private val auditionRepository: AdminAuditionRepository,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val bucket: String
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createAuditionRole(image: MultipartFile, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, CreateAuditionRoleRequest::class.java)
|
||||||
|
val auditionRole = AuditionRole(
|
||||||
|
name = request.name,
|
||||||
|
information = request.information,
|
||||||
|
auditionScriptUrl = request.auditionScriptUrl
|
||||||
|
)
|
||||||
|
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
auditionRole.audition = audition
|
||||||
|
repository.save(auditionRole)
|
||||||
|
|
||||||
|
val fileName = generateFileName("audition_role")
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audition/role/${auditionRole.id}/$fileName"
|
||||||
|
)
|
||||||
|
auditionRole.imagePath = imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||||
|
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
|
if (!request.name.isNullOrBlank()) {
|
||||||
|
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
||||||
|
auditionRole.name = request.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.information.isNullOrBlank()) {
|
||||||
|
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
||||||
|
auditionRole.information = request.information
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.auditionScriptUrl.isNullOrBlank()) {
|
||||||
|
auditionRole.auditionScriptUrl = request.auditionScriptUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status != null) {
|
||||||
|
auditionRole.status = request.status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
auditionRole.isActive = request.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
val fileName = generateFileName("audition_role")
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audition/role/${auditionRole.id}/$fileName"
|
||||||
|
)
|
||||||
|
auditionRole.imagePath = imagePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
|
||||||
|
return repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): GetAuditionRoleApplicantResponse {
|
||||||
|
val totalCount = repository.getAuditionApplicantTotalCount(auditionRoleId = auditionRoleId)
|
||||||
|
val items = repository.getAuditionApplicantList(auditionRoleId = auditionRoleId, offset = offset, limit = limit)
|
||||||
|
return GetAuditionRoleApplicantResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
|
||||||
|
data class CreateAuditionRoleRequest(
|
||||||
|
val auditionId: Long,
|
||||||
|
val name: String,
|
||||||
|
val information: String,
|
||||||
|
val auditionScriptUrl: String
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
if (auditionId < 0) {
|
||||||
|
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isBlank() || name.length < 2) {
|
||||||
|
throw SodaException("캐릭터명을 입력하세요")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||||
|
throw SodaException("오디션 대본 URL을 입력하세요")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (information.isBlank() || information.length < 10) {
|
||||||
|
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetAuditionRoleApplicantResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetAuditionRoleApplicantItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAuditionRoleApplicantItem @QueryProjection constructor(
|
||||||
|
val applicantId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val phoneNumber: String,
|
||||||
|
val voiceUrl: String,
|
||||||
|
val voteCount: Long
|
||||||
|
)
|
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetAuditionRoleDetailResponse @QueryProjection constructor(
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val information: String,
|
||||||
|
val auditionScriptUrl: String
|
||||||
|
)
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.audition.role
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
|
||||||
|
data class UpdateAuditionRoleRequest(
|
||||||
|
val id: Long,
|
||||||
|
val name: String? = null,
|
||||||
|
val information: String? = null,
|
||||||
|
val auditionScriptUrl: String? = null,
|
||||||
|
val status: AuditionStatus? = null,
|
||||||
|
val isActive: Boolean? = null
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
if (id < 0) {
|
||||||
|
throw SodaException("잘못된 요청입니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,93 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/calculate")
|
||||||
|
class AdminCalculateController(private val service: AdminCalculateService) {
|
||||||
|
@GetMapping("/live")
|
||||||
|
fun getCalculateLive(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
|
||||||
|
|
||||||
|
@GetMapping("/content-list")
|
||||||
|
fun getCalculateContentList(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr))
|
||||||
|
|
||||||
|
@GetMapping("/cumulative-sales-by-content")
|
||||||
|
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
|
||||||
|
service.getCumulativeSalesByContent(pageable.offset, pageable.pageSize.toLong())
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/content-donation-list")
|
||||||
|
fun getCalculateContentDonationList(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr))
|
||||||
|
|
||||||
|
@GetMapping("/community-post")
|
||||||
|
fun getCalculateCommunityPost(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCalculateCommunityPost(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/live-by-creator")
|
||||||
|
fun getCalculateLiveByCreator(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCalculateLiveByCreator(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/content-by-creator")
|
||||||
|
fun getCalculateContentByCreator(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCalculateContentByCreator(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/community-by-creator")
|
||||||
|
fun getCalculateCommunityByCreator(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCalculateCommunityByCreator(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,428 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||||
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
|
fun getCalculateLive(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateLiveQueryData> {
|
||||||
|
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateLiveQueryData(
|
||||||
|
member.email,
|
||||||
|
member.nickname,
|
||||||
|
formattedDate,
|
||||||
|
liveRoom.title,
|
||||||
|
liveRoom.price,
|
||||||
|
useCan.canUsage,
|
||||||
|
useCan.id.count(),
|
||||||
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
|
creatorSettlementRatio.liveSettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.room, liveRoom)
|
||||||
|
.innerJoin(liveRoom.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
||||||
|
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||||
|
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateContentQueryData(
|
||||||
|
member.nickname,
|
||||||
|
audioContent.title,
|
||||||
|
getFormattedDate(audioContent.createdAt),
|
||||||
|
orderFormattedDate,
|
||||||
|
order.type,
|
||||||
|
order.can,
|
||||||
|
order.id.count(),
|
||||||
|
order.can.sum(),
|
||||||
|
order.point.sum(),
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
order.createdAt.goe(startDate)
|
||||||
|
.and(order.createdAt.loe(endDate))
|
||||||
|
.and(order.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
audioContent.id,
|
||||||
|
order.type,
|
||||||
|
orderFormattedDate,
|
||||||
|
order.can,
|
||||||
|
pointGroup,
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||||
|
return Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
dateTimePath,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCumulativeSalesByContentTotalCount(): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.where(order.isActive.isTrue)
|
||||||
|
.groupBy(member.id, audioContent.id, order.can)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCumulativeSalesByContentQueryData(
|
||||||
|
member.nickname,
|
||||||
|
audioContent.title,
|
||||||
|
getFormattedDate(audioContent.createdAt),
|
||||||
|
order.type,
|
||||||
|
order.can,
|
||||||
|
order.id.count(),
|
||||||
|
order.can.sum(),
|
||||||
|
order.point.sum(),
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(order.isActive.isTrue)
|
||||||
|
.groupBy(
|
||||||
|
member.id,
|
||||||
|
audioContent.id,
|
||||||
|
order.type,
|
||||||
|
order.can,
|
||||||
|
pointGroup,
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateContentDonationList(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime
|
||||||
|
): List<GetCalculateContentDonationQueryData> {
|
||||||
|
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateContentDonationQueryData(
|
||||||
|
member.nickname,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.price,
|
||||||
|
getFormattedDate(audioContent.createdAt),
|
||||||
|
donationFormattedDate,
|
||||||
|
useCan.id.count(),
|
||||||
|
useCan.can.add(useCan.rewardCan).sum()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.DONATION))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(donationFormattedDate, audioContent.id)
|
||||||
|
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateCommunityPostTotalCount(startDate: LocalDateTime?, endDate: LocalDateTime?): Int {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunity.id)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
|
.innerJoin(creatorCommunity.member, member)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(formattedDate, creatorCommunity.id)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateCommunityPostList(
|
||||||
|
startDate: LocalDateTime?,
|
||||||
|
endDate: LocalDateTime?,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetCalculateCommunityPostQueryData> {
|
||||||
|
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateCommunityPostQueryData(
|
||||||
|
member.nickname,
|
||||||
|
Expressions.stringTemplate("substring({0}, 1, 10)", creatorCommunity.content),
|
||||||
|
formattedDate,
|
||||||
|
creatorCommunity.price,
|
||||||
|
useCan.id.count(),
|
||||||
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
|
creatorSettlementRatio.communitySettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
|
.innerJoin(creatorCommunity.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(formattedDate, creatorCommunity.id, creatorSettlementRatio.communitySettlementRatio)
|
||||||
|
.orderBy(member.id.asc(), formattedDate.desc(), creatorCommunity.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateLiveByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.room, liveRoom)
|
||||||
|
.innerJoin(liveRoom.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(member.id)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateLiveByCreator(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetCalculateByCreatorQueryData> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateByCreatorQueryData(
|
||||||
|
member.email,
|
||||||
|
member.nickname,
|
||||||
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
|
creatorSettlementRatio.liveSettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.room, liveRoom)
|
||||||
|
.innerJoin(liveRoom.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(member.id, creatorSettlementRatio.liveSettlementRatio)
|
||||||
|
.orderBy(member.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateContentByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
order.createdAt.goe(startDate)
|
||||||
|
.and(order.createdAt.loe(endDate))
|
||||||
|
.and(order.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(member.id)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateContentByCreator(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetCalculateByCreatorQueryData> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateByCreatorQueryData(
|
||||||
|
member.email,
|
||||||
|
member.nickname,
|
||||||
|
order.can.sum(),
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
order.createdAt.goe(startDate)
|
||||||
|
.and(order.createdAt.loe(endDate))
|
||||||
|
.and(order.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(member.id)
|
||||||
|
.orderBy(member.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateCommunityByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
|
.innerJoin(creatorCommunity.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(member.id)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateCommunityByCreator(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<GetCalculateByCreatorQueryData> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCalculateByCreatorQueryData(
|
||||||
|
member.email,
|
||||||
|
member.nickname,
|
||||||
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
|
creatorSettlementRatio.communitySettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
|
.innerJoin(creatorCommunity.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
.and(useCan.createdAt.goe(startDate))
|
||||||
|
.and(useCan.createdAt.loe(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(member.id, creatorSettlementRatio.communitySettlementRatio)
|
||||||
|
.orderBy(member.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,142 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.cache.annotation.Cacheable
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateLive:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
|
)
|
||||||
|
fun getCalculateLive(startDateStr: String, endDateStr: String): List<GetCalculateLiveResponse> {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
return repository
|
||||||
|
.getCalculateLive(startDate, endDate)
|
||||||
|
.map { it.toGetCalculateLiveResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateContent:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
|
)
|
||||||
|
fun getCalculateContentList(startDateStr: String, endDateStr: String): List<GetCalculateContentResponse> {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
return repository
|
||||||
|
.getCalculateContentList(startDate, endDate)
|
||||||
|
.map { it.toGetCalculateContentResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'cumulativeSalesByContent:' + " + "#offset + ':' + #limit"
|
||||||
|
)
|
||||||
|
fun getCumulativeSalesByContent(offset: Long, limit: Long): GetCumulativeSalesByContentResponse {
|
||||||
|
val totalCount = repository.getCumulativeSalesByContentTotalCount()
|
||||||
|
val items = repository
|
||||||
|
.getCumulativeSalesByContent(offset, limit)
|
||||||
|
.map { it.toCumulativeSalesByContentItem() }
|
||||||
|
|
||||||
|
return GetCumulativeSalesByContentResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
|
)
|
||||||
|
fun getCalculateContentDonationList(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String
|
||||||
|
): List<GetCalculateContentDonationResponse> {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
return repository
|
||||||
|
.getCalculateContentDonationList(startDate, endDate)
|
||||||
|
.map { it.toGetCalculateContentDonationResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateCommunityPost:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset"
|
||||||
|
)
|
||||||
|
fun getCalculateCommunityPost(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): GetCreatorCalculateCommunityPostResponse {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
|
||||||
|
val items = repository
|
||||||
|
.getCalculateCommunityPostList(startDate, endDate, offset, limit)
|
||||||
|
.map { it.toGetCalculateCommunityPostResponse() }
|
||||||
|
|
||||||
|
return GetCreatorCalculateCommunityPostResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateLiveByCreator(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
) = run {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)
|
||||||
|
val items = repository
|
||||||
|
.getCalculateLiveByCreator(startDate, endDate, offset, limit)
|
||||||
|
.map { it.toGetCalculateByCreator() }
|
||||||
|
|
||||||
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateContentByCreator(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
) = run {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate)
|
||||||
|
val items = repository
|
||||||
|
.getCalculateContentByCreator(startDate, endDate, offset, limit)
|
||||||
|
.map { it.toGetCalculateByCreator() }
|
||||||
|
|
||||||
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCalculateCommunityByCreator(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
) = run {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate)
|
||||||
|
val items = repository
|
||||||
|
.getCalculateCommunityByCreator(startDate, endDate, offset, limit)
|
||||||
|
.map { it.toGetCalculateByCreator() }
|
||||||
|
|
||||||
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,14 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCalculateByCreatorItem(
|
||||||
|
@JsonProperty("email") val email: String,
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,44 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCalculateByCreatorQueryData @QueryProjection constructor(
|
||||||
|
val email: String,
|
||||||
|
val nickname: String,
|
||||||
|
val totalCan: Int,
|
||||||
|
val settlementRatio: Int?
|
||||||
|
) {
|
||||||
|
fun toGetCalculateByCreator(): GetCalculateByCreatorItem {
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (settlementRatio != null) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
// 입금액
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
return GetCalculateByCreatorItem(
|
||||||
|
email = email,
|
||||||
|
nickname = nickname,
|
||||||
|
totalCan = totalCan,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
data class GetCalculateByCreatorResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCalculateByCreatorItem>
|
||||||
|
)
|
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCalculateCommunityPostQueryData @QueryProjection constructor(
|
||||||
|
val nickname: String,
|
||||||
|
val title: String,
|
||||||
|
val date: String,
|
||||||
|
val can: Int,
|
||||||
|
val numberOfPurchase: Long,
|
||||||
|
val totalCan: Int,
|
||||||
|
val settlementRatio: Int?
|
||||||
|
) {
|
||||||
|
fun toGetCalculateCommunityPostResponse(): GetCalculateCommunityPostResponse {
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (settlementRatio != null) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
// 입금액
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
return GetCalculateCommunityPostResponse(
|
||||||
|
nickname = nickname,
|
||||||
|
title = title,
|
||||||
|
date = date,
|
||||||
|
can = can,
|
||||||
|
numberOfPurchase = numberOfPurchase.toInt(),
|
||||||
|
totalCan = totalCan,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCalculateCommunityPostResponse(
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("date") val date: String,
|
||||||
|
@JsonProperty("can") val can: Int,
|
||||||
|
@JsonProperty("numberOfPurchase") val numberOfPurchase: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,66 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCalculateContentDonationQueryData @QueryProjection constructor(
|
||||||
|
// 등록 크리에이터 닉네임
|
||||||
|
val nickname: String,
|
||||||
|
// 콘텐츠 제목
|
||||||
|
val title: String,
|
||||||
|
// 콘텐츠 가격
|
||||||
|
val price: Int,
|
||||||
|
// 콘텐츠 등록 날짜
|
||||||
|
val registrationDate: String,
|
||||||
|
// 후원 날짜
|
||||||
|
val donationDate: String,
|
||||||
|
// 인원
|
||||||
|
val numberOfDonation: Long,
|
||||||
|
// 합계
|
||||||
|
val totalCan: Int
|
||||||
|
) {
|
||||||
|
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액
|
||||||
|
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
|
||||||
|
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (price > 0) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.9))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
// 입금액
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
val paidOrFree = if (price > 0) {
|
||||||
|
"유료"
|
||||||
|
} else {
|
||||||
|
"무료"
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetCalculateContentDonationResponse(
|
||||||
|
nickname = nickname,
|
||||||
|
title = title,
|
||||||
|
paidOrFree = paidOrFree,
|
||||||
|
registrationDate = registrationDate,
|
||||||
|
donationDate = donationDate,
|
||||||
|
numberOfDonation = numberOfDonation.toInt(),
|
||||||
|
totalCan = totalCan,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCalculateContentDonationResponse(
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("paidOrFree") val paidOrFree: String,
|
||||||
|
@JsonProperty("registrationDate") val registrationDate: String,
|
||||||
|
@JsonProperty("donationDate") val donationDate: String,
|
||||||
|
@JsonProperty("numberOfDonation") val numberOfDonation: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,73 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCalculateContentQueryData @QueryProjection constructor(
|
||||||
|
// 등록 크리에이터 닉네임
|
||||||
|
val nickname: String,
|
||||||
|
// 콘텐츠 제목
|
||||||
|
val title: String,
|
||||||
|
// 콘텐츠 등록 날짜
|
||||||
|
val registrationDate: String,
|
||||||
|
// 콘텐츠 판매 날짜
|
||||||
|
val saleDate: String,
|
||||||
|
// 대여/소장 구분
|
||||||
|
val orderType: OrderType,
|
||||||
|
// 판매 금액(캔)
|
||||||
|
val orderPrice: Int,
|
||||||
|
// 인원
|
||||||
|
val numberOfPeople: Long,
|
||||||
|
// 합계
|
||||||
|
val totalCan: Int,
|
||||||
|
// 포인트
|
||||||
|
val totalPoint: Int,
|
||||||
|
// 정산비율
|
||||||
|
val settlementRatio: Int?
|
||||||
|
) {
|
||||||
|
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||||
|
val orderTypeStr = if (totalPoint > 0) {
|
||||||
|
"포인트"
|
||||||
|
} else if (orderType == OrderType.RENTAL) {
|
||||||
|
"대여"
|
||||||
|
} else {
|
||||||
|
"소장"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (settlementRatio != null) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
return GetCalculateContentResponse(
|
||||||
|
nickname = nickname,
|
||||||
|
title = title,
|
||||||
|
registrationDate = registrationDate,
|
||||||
|
saleDate = saleDate,
|
||||||
|
orderType = orderTypeStr,
|
||||||
|
orderPrice = orderPrice,
|
||||||
|
numberOfPeople = numberOfPeople.toInt(),
|
||||||
|
totalCan = totalCan,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCalculateContentResponse(
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("registrationDate") val registrationDate: String,
|
||||||
|
@JsonProperty("saleDate") val saleDate: String,
|
||||||
|
@JsonProperty("orderType") val orderType: String,
|
||||||
|
@JsonProperty("orderPrice") val orderPrice: Int,
|
||||||
|
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,84 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCalculateLiveQueryData @QueryProjection constructor(
|
||||||
|
val email: String,
|
||||||
|
val nickname: String,
|
||||||
|
val date: String,
|
||||||
|
val title: String,
|
||||||
|
// 유료방 입장 금액
|
||||||
|
val entranceFee: Int,
|
||||||
|
// 코인 사용 구분
|
||||||
|
val canUsage: CanUsage,
|
||||||
|
// 참여인원
|
||||||
|
val memberCount: Long,
|
||||||
|
// 합계
|
||||||
|
val totalAmount: Int,
|
||||||
|
// 정산비율
|
||||||
|
val settlementRatio: Int?
|
||||||
|
) {
|
||||||
|
fun toGetCalculateLiveResponse(): GetCalculateLiveResponse {
|
||||||
|
val canUsageStr = when (canUsage) {
|
||||||
|
CanUsage.LIVE -> {
|
||||||
|
"유료"
|
||||||
|
}
|
||||||
|
|
||||||
|
CanUsage.SPIN_ROULETTE -> {
|
||||||
|
"룰렛"
|
||||||
|
}
|
||||||
|
|
||||||
|
CanUsage.HEART -> {
|
||||||
|
"하트"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
"후원"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val numberOfPeople = if (canUsage == CanUsage.LIVE) {
|
||||||
|
memberCount.toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalAmount).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (settlementRatio != null) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
// 입금액
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
return GetCalculateLiveResponse(
|
||||||
|
email = email,
|
||||||
|
nickname = nickname,
|
||||||
|
date = date,
|
||||||
|
title = title,
|
||||||
|
entranceFee = entranceFee,
|
||||||
|
canUsageStr = canUsageStr,
|
||||||
|
numberOfPeople = numberOfPeople,
|
||||||
|
totalAmount = totalAmount,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetCalculateLiveResponse(
|
||||||
|
@JsonProperty("email") val email: String,
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("date") val date: String,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("entranceFee") val entranceFee: Int,
|
||||||
|
@JsonProperty("canUsageStr") val canUsageStr: String,
|
||||||
|
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
|
||||||
|
@JsonProperty("totalAmount") val totalAmount: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,92 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
||||||
|
// 등록 크리에이터 닉네임
|
||||||
|
val nickname: String,
|
||||||
|
// 콘텐츠 제목
|
||||||
|
val title: String,
|
||||||
|
// 콘텐츠 등록 날짜
|
||||||
|
val registrationDate: String,
|
||||||
|
// 대여/소장 구분
|
||||||
|
val orderType: OrderType,
|
||||||
|
// 판매 금액(캔)
|
||||||
|
val orderPrice: Int,
|
||||||
|
// 인원
|
||||||
|
val numberOfPeople: Long,
|
||||||
|
// 합계
|
||||||
|
val totalCan: Int,
|
||||||
|
// 포인트
|
||||||
|
val totalPoint: Int,
|
||||||
|
// 정산비율
|
||||||
|
val settlementRatio: Int?
|
||||||
|
) {
|
||||||
|
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||||
|
val orderTypeStr = if (totalPoint > 0) {
|
||||||
|
"포인트"
|
||||||
|
} else if (orderType == OrderType.RENTAL) {
|
||||||
|
"대여"
|
||||||
|
} else {
|
||||||
|
"소장"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
|
// 결제수수료 : 6.6%
|
||||||
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
|
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||||
|
val settlementAmount = if (settlementRatio != null) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원천세 = 정산금액의 3.3%
|
||||||
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
|
// 입금액
|
||||||
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
|
return CumulativeSalesByContentItem(
|
||||||
|
nickname = nickname,
|
||||||
|
title = title,
|
||||||
|
registrationDate = registrationDate,
|
||||||
|
orderType = orderTypeStr,
|
||||||
|
orderPrice = orderPrice,
|
||||||
|
numberOfPeople = numberOfPeople.toInt(),
|
||||||
|
totalCan = totalCan,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||||
|
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GetCumulativeSalesByContentResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Int,
|
||||||
|
@JsonProperty("items") val items: List<CumulativeSalesByContentItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CumulativeSalesByContentItem(
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("registrationDate") val registrationDate: String,
|
||||||
|
@JsonProperty("orderType") val orderType: String,
|
||||||
|
@JsonProperty("orderPrice") val orderPrice: Int,
|
||||||
|
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||||
|
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||||
|
@JsonProperty("tax") val tax: Int,
|
||||||
|
@JsonProperty("depositAmount") val depositAmount: Int
|
||||||
|
)
|
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
data class CreateCreatorSettlementRatioRequest(
|
||||||
|
val memberId: Long,
|
||||||
|
val subsidy: Int,
|
||||||
|
val liveSettlementRatio: Int,
|
||||||
|
val contentSettlementRatio: Int,
|
||||||
|
val communitySettlementRatio: Int
|
||||||
|
) {
|
||||||
|
fun toEntity() = CreatorSettlementRatio(
|
||||||
|
subsidy = subsidy,
|
||||||
|
liveSettlementRatio = liveSettlementRatio,
|
||||||
|
contentSettlementRatio = contentSettlementRatio,
|
||||||
|
communitySettlementRatio = communitySettlementRatio
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,38 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.OneToOne
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class CreatorSettlementRatio(
|
||||||
|
var subsidy: Int,
|
||||||
|
var liveSettlementRatio: Int,
|
||||||
|
var contentSettlementRatio: Int,
|
||||||
|
var communitySettlementRatio: Int
|
||||||
|
) : BaseEntity() {
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
|
var member: Member? = null
|
||||||
|
|
||||||
|
var deletedAt: LocalDateTime? = null
|
||||||
|
|
||||||
|
fun softDelete() {
|
||||||
|
this.deletedAt = LocalDateTime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore() {
|
||||||
|
this.deletedAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||||
|
this.subsidy = subsidy
|
||||||
|
this.liveSettlementRatio = live
|
||||||
|
this.contentSettlementRatio = content
|
||||||
|
this.communitySettlementRatio = community
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/calculate/ratio")
|
||||||
|
class CreatorSettlementRatioController(private val service: CreatorSettlementRatioService) {
|
||||||
|
@PostMapping
|
||||||
|
fun createCreatorSettlementRatio(
|
||||||
|
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||||
|
) = ApiResponse.ok(service.createCreatorSettlementRatio(request))
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getCreatorSettlementRatio(
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCreatorSettlementRatio(
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("/update")
|
||||||
|
fun updateCreatorSettlementRatio(
|
||||||
|
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||||
|
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
||||||
|
|
||||||
|
@PostMapping("/delete/{memberId}")
|
||||||
|
fun deleteCreatorSettlementRatio(
|
||||||
|
@PathVariable memberId: Long
|
||||||
|
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
||||||
|
}
|
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface CreatorSettlementRatioRepository :
|
||||||
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
|
CreatorSettlementRatioQueryRepository {
|
||||||
|
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
|
fun getCreatorSettlementRatioTotalCount(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreatorSettlementRatioQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : CreatorSettlementRatioQueryRepository {
|
||||||
|
override fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCreatorSettlementRatioItem(
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
creatorSettlementRatio.subsidy,
|
||||||
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
|
creatorSettlementRatio.contentSettlementRatio,
|
||||||
|
creatorSettlementRatio.communitySettlementRatio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(creatorSettlementRatio)
|
||||||
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
|
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCreatorSettlementRatioTotalCount(): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorSettlementRatio.id)
|
||||||
|
.from(creatorSettlementRatio)
|
||||||
|
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CreatorSettlementRatioService(
|
||||||
|
private val repository: CreatorSettlementRatioRepository,
|
||||||
|
private val memberRepository: MemberRepository
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
|
if (existing != null) {
|
||||||
|
// revive if soft-deleted, then update values
|
||||||
|
existing.restore()
|
||||||
|
existing.updateValues(
|
||||||
|
request.subsidy,
|
||||||
|
request.liveSettlementRatio,
|
||||||
|
request.contentSettlementRatio,
|
||||||
|
request.communitySettlementRatio
|
||||||
|
)
|
||||||
|
repository.save(existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
creatorSettlementRatio.member = creator
|
||||||
|
repository.save(creatorSettlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
}
|
||||||
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
|
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||||
|
existing.restore()
|
||||||
|
existing.updateValues(
|
||||||
|
request.subsidy,
|
||||||
|
request.liveSettlementRatio,
|
||||||
|
request.contentSettlementRatio,
|
||||||
|
request.communitySettlementRatio
|
||||||
|
)
|
||||||
|
repository.save(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||||
|
val existing = repository.findByMemberId(memberId)
|
||||||
|
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||||
|
existing.softDelete()
|
||||||
|
repository.save(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
val items = repository.getCreatorSettlementRatio(offset, limit)
|
||||||
|
|
||||||
|
return GetCreatorSettlementRatioResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetCreatorSettlementRatioResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetCreatorSettlementRatioItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val subsidy: Int,
|
||||||
|
val liveSettlementRatio: Int,
|
||||||
|
val contentSettlementRatio: Int,
|
||||||
|
val communitySettlementRatio: Int
|
||||||
|
)
|
@@ -0,0 +1,7 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
data class AdminCanChargeRequest(
|
||||||
|
val memberId: Long,
|
||||||
|
val method: String,
|
||||||
|
val can: Int
|
||||||
|
)
|
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/can")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
|
@PostMapping
|
||||||
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun deleteCan(@PathVariable id: Long) = ApiResponse.ok(service.deleteCan(id))
|
||||||
|
|
||||||
|
@PostMapping("/charge")
|
||||||
|
fun charge(@RequestBody request: AdminCanChargeRequest) = ApiResponse.ok(service.charge(request))
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AdminCanRepository : JpaRepository<Can, Long>
|
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
|
|
||||||
|
data class AdminCanRequest(
|
||||||
|
val can: Int,
|
||||||
|
val rewardCan: Int,
|
||||||
|
val price: Int
|
||||||
|
) {
|
||||||
|
fun toEntity(): Can {
|
||||||
|
var title = "${can.moneyFormat()} 캔"
|
||||||
|
if (rewardCan > 0) {
|
||||||
|
title = "$title + ${rewardCan.moneyFormat()} 캔"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Can(
|
||||||
|
title = title,
|
||||||
|
can = can,
|
||||||
|
rewardCan = rewardCan,
|
||||||
|
price = price,
|
||||||
|
status = CanStatus.SALE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,56 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminCanService(
|
||||||
|
private val repository: AdminCanRepository,
|
||||||
|
private val chargeRepository: ChargeRepository,
|
||||||
|
private val memberRepository: AdminMemberRepository
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun saveCan(request: AdminCanRequest) {
|
||||||
|
repository.save(request.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteCan(id: Long) {
|
||||||
|
val can = repository.findByIdOrNull(id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
can.status = CanStatus.END_OF_SALE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun charge(request: AdminCanChargeRequest) {
|
||||||
|
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||||
|
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||||
|
|
||||||
|
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||||
|
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||||
|
|
||||||
|
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||||
|
charge.title = "${request.can.moneyFormat()} 캔"
|
||||||
|
charge.member = member
|
||||||
|
|
||||||
|
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||||
|
payment.method = request.method
|
||||||
|
charge.payment = payment
|
||||||
|
|
||||||
|
chargeRepository.save(charge)
|
||||||
|
|
||||||
|
member.pgRewardCan += charge.rewardCan
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/charge/status")
|
||||||
|
class AdminChargeStatusController(private val service: AdminChargeStatusService) {
|
||||||
|
@GetMapping
|
||||||
|
fun getChargeStatus(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String
|
||||||
|
) = ApiResponse.ok(service.getChargeStatus(startDateStr, endDateStr))
|
||||||
|
|
||||||
|
@GetMapping("/detail")
|
||||||
|
fun getChargeStatusDetail(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam paymentGateway: PaymentGateway
|
||||||
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||||
|
}
|
@@ -0,0 +1,97 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
|
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||||
|
val formattedDate = Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
charge.createdAt,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetChargeStatusQueryDto(
|
||||||
|
formattedDate,
|
||||||
|
payment.price.sum(),
|
||||||
|
can1.price.sum(),
|
||||||
|
payment.id.count(),
|
||||||
|
payment.paymentGateway
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(payment)
|
||||||
|
.innerJoin(payment.charge, charge)
|
||||||
|
.leftJoin(charge.can, can1)
|
||||||
|
.where(
|
||||||
|
charge.createdAt.goe(startDate)
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
)
|
||||||
|
.groupBy(formattedDate, payment.paymentGateway)
|
||||||
|
.orderBy(formattedDate.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChargeStatusDetail(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
paymentGateway: PaymentGateway
|
||||||
|
): List<GetChargeStatusDetailQueryDto> {
|
||||||
|
val formattedDate = Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
charge.createdAt,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
"%Y-%m-%d %H:%i:%s"
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetChargeStatusDetailQueryDto(
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
payment.method.coalesce(""),
|
||||||
|
payment.price,
|
||||||
|
can1.price,
|
||||||
|
payment.locale.coalesce(""),
|
||||||
|
formattedDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(charge)
|
||||||
|
.innerJoin(charge.member, member)
|
||||||
|
.innerJoin(charge.payment, payment)
|
||||||
|
.leftJoin(charge.can, can1)
|
||||||
|
.where(
|
||||||
|
charge.createdAt.goe(startDate)
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
.and(payment.paymentGateway.eq(paymentGateway))
|
||||||
|
)
|
||||||
|
.orderBy(formattedDate.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,91 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) {
|
||||||
|
fun getChargeStatus(startDateStr: String, endDateStr: String): List<GetChargeStatusResponse> {
|
||||||
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
var totalChargeAmount = 0
|
||||||
|
var totalChargeCount = 0L
|
||||||
|
|
||||||
|
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||||
|
.asSequence()
|
||||||
|
.map {
|
||||||
|
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
||||||
|
it.pgChargeAmount
|
||||||
|
} else {
|
||||||
|
it.appleChargeAmount.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val chargeCount = it.chargeCount
|
||||||
|
|
||||||
|
totalChargeAmount += chargeAmount
|
||||||
|
totalChargeCount += chargeCount
|
||||||
|
|
||||||
|
GetChargeStatusResponse(
|
||||||
|
date = it.date,
|
||||||
|
chargeAmount = chargeAmount,
|
||||||
|
chargeCount = chargeCount,
|
||||||
|
pg = it.paymentGateWay.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
chargeStatusList.add(
|
||||||
|
0,
|
||||||
|
GetChargeStatusResponse(
|
||||||
|
date = "합계",
|
||||||
|
chargeAmount = totalChargeAmount,
|
||||||
|
chargeCount = totalChargeCount,
|
||||||
|
pg = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chargeStatusList.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChargeStatusDetail(
|
||||||
|
startDateStr: String,
|
||||||
|
paymentGateway: PaymentGateway
|
||||||
|
): List<GetChargeStatusDetailResponse> {
|
||||||
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
val endDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(23, 59, 59)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||||
|
.asSequence()
|
||||||
|
.map {
|
||||||
|
GetChargeStatusDetailResponse(
|
||||||
|
memberId = it.memberId,
|
||||||
|
nickname = it.nickname,
|
||||||
|
method = it.method,
|
||||||
|
amount = it.appleChargeAmount.toInt(),
|
||||||
|
locale = it.locale,
|
||||||
|
datetime = it.datetime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val method: String,
|
||||||
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
|
val locale: String,
|
||||||
|
val datetime: String
|
||||||
|
)
|
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
data class GetChargeStatusDetailResponse(
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val method: String,
|
||||||
|
val amount: Int,
|
||||||
|
val locale: String,
|
||||||
|
val datetime: String
|
||||||
|
)
|
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
|
||||||
|
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||||
|
val date: String,
|
||||||
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val paymentGateWay: PaymentGateway
|
||||||
|
)
|
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
data class GetChargeStatusResponse(
|
||||||
|
val date: String,
|
||||||
|
val chargeAmount: Int,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val pg: String
|
||||||
|
)
|
@@ -0,0 +1,229 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminChatBannerController(
|
||||||
|
private val bannerService: ChatCharacterBannerService,
|
||||||
|
private val adminCharacterService: AdminChatCharacterService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 배너 목록 조회 API
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 페이징된 배너 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getBannerList(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||||
|
val banners = bannerService.getActiveBanners(pageable)
|
||||||
|
val response = ChatCharacterBannerListPageResponse(
|
||||||
|
totalCount = banners.totalElements,
|
||||||
|
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 상세 조회 API
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID
|
||||||
|
* @return 배너 상세 정보
|
||||||
|
*/
|
||||||
|
@GetMapping("/{bannerId}")
|
||||||
|
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||||
|
val banner = bannerService.getBannerById(bannerId)
|
||||||
|
val response = ChatCharacterBannerResponse.from(banner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 API (배너 등록을 위한)
|
||||||
|
*
|
||||||
|
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 검색된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/search-character")
|
||||||
|
fun searchCharacters(
|
||||||
|
@RequestParam searchTerm: String,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||||
|
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
|
||||||
|
val response = ChatCharacterSearchListPageResponse(
|
||||||
|
totalCount = pageResult.totalElements,
|
||||||
|
content = pageResult.content
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 등록 API
|
||||||
|
*
|
||||||
|
* @param image 배너 이미지
|
||||||
|
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
|
||||||
|
* @return 등록된 배너 정보
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun registerBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(
|
||||||
|
requestString,
|
||||||
|
ChatCharacterBannerRegisterRequest::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
|
||||||
|
val banner = bannerService.registerBanner(
|
||||||
|
characterId = request.characterId,
|
||||||
|
imagePath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 배너 ID를 사용하여 이미지 업로드
|
||||||
|
val imagePath = saveImage(banner.id!!, image)
|
||||||
|
|
||||||
|
// 3. 이미지 경로로 배너 업데이트
|
||||||
|
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||||
|
|
||||||
|
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지를 S3에 업로드하고 경로를 반환
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID (이미지 경로에 사용)
|
||||||
|
* @param image 업로드할 이미지 파일
|
||||||
|
* @return 업로드된 이미지 경로
|
||||||
|
*/
|
||||||
|
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
|
||||||
|
val fileName = generateFileName("character-banner")
|
||||||
|
|
||||||
|
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "characters/banners/$bannerId/$fileName",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 수정 API
|
||||||
|
*
|
||||||
|
* @param image 배너 이미지
|
||||||
|
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
|
||||||
|
* @return 수정된 배너 정보
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun updateBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(
|
||||||
|
requestString,
|
||||||
|
ChatCharacterBannerUpdateRequest::class.java
|
||||||
|
)
|
||||||
|
// 배너 정보 조회
|
||||||
|
bannerService.getBannerById(request.bannerId)
|
||||||
|
|
||||||
|
// 배너 ID를 사용하여 이미지 업로드
|
||||||
|
val imagePath = saveImage(request.bannerId, image)
|
||||||
|
|
||||||
|
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
|
||||||
|
val updatedBanner = bannerService.updateBanner(
|
||||||
|
bannerId = request.bannerId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
characterId = request.characterId
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 삭제 API (소프트 삭제)
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID
|
||||||
|
* @return 성공 여부
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{bannerId}")
|
||||||
|
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||||
|
bannerService.deleteBanner(bannerId)
|
||||||
|
|
||||||
|
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 정렬 순서 일괄 변경 API
|
||||||
|
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||||
|
*
|
||||||
|
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
||||||
|
* @return 성공 메시지
|
||||||
|
*/
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateBannerOrders(
|
||||||
|
@RequestBody request: UpdateBannerOrdersRequest
|
||||||
|
) = run {
|
||||||
|
bannerService.updateBannerOrders(request.ids)
|
||||||
|
|
||||||
|
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/chat/calculate")
|
||||||
|
class AdminChatCalculateController(
|
||||||
|
private val service: AdminChatCalculateService
|
||||||
|
) {
|
||||||
|
@GetMapping("/characters")
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCharacterCalculate(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
sort,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminChatCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<ChatCharacterCalculateQueryData> {
|
||||||
|
val imageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val messageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val quotaCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val imageSum = imageCanExpr.sum()
|
||||||
|
val messageSum = messageCanExpr.sum()
|
||||||
|
val quotaSum = quotaCanExpr.sum()
|
||||||
|
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
||||||
|
|
||||||
|
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
val characterNameAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}), '')",
|
||||||
|
c1.name,
|
||||||
|
c2.name
|
||||||
|
)
|
||||||
|
val characterImagePathAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}))",
|
||||||
|
c1.imagePath,
|
||||||
|
c2.imagePath
|
||||||
|
)
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
ChatCharacterCalculateQueryData::class.java,
|
||||||
|
characterIdExpr,
|
||||||
|
characterNameAgg,
|
||||||
|
characterImagePathAgg.prepend("/").prepend(imageHost),
|
||||||
|
imageSum,
|
||||||
|
messageSum,
|
||||||
|
quotaSum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
||||||
|
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
||||||
|
|
||||||
|
ChatCharacterCalculateSort.LATEST_DESC ->
|
||||||
|
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharacterCalculateTotalCount(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime
|
||||||
|
): Int {
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(characterIdExpr)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChatCalculateService(
|
||||||
|
private val repository: AdminChatCalculateQueryRepository
|
||||||
|
) {
|
||||||
|
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
pageSize: Int
|
||||||
|
): ChatCharacterCalculateResponse {
|
||||||
|
// 날짜 유효성 검증 (KST 기준)
|
||||||
|
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
||||||
|
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
||||||
|
val todayKst = LocalDate.now(kstZone)
|
||||||
|
|
||||||
|
if (endDate.isAfter(todayKst)) {
|
||||||
|
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
||||||
|
}
|
||||||
|
if (startDate.isAfter(endDate)) {
|
||||||
|
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
||||||
|
}
|
||||||
|
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||||
|
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val startUtc = startDateStr.convertLocalDateTime()
|
||||||
|
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
||||||
|
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
||||||
|
val items = rows.map { it.toItem() }
|
||||||
|
return ChatCharacterCalculateResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,62 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
// 정렬 옵션
|
||||||
|
enum class ChatCharacterCalculateSort {
|
||||||
|
TOTAL_SALES_DESC,
|
||||||
|
LATEST_DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryDSL 프로젝션용 DTO
|
||||||
|
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
||||||
|
val characterId: Long,
|
||||||
|
val characterName: String,
|
||||||
|
val characterImagePath: String?,
|
||||||
|
val imagePurchaseCan: Int?,
|
||||||
|
val messagePurchaseCan: Int?,
|
||||||
|
val quotaPurchaseCan: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (아이템)
|
||||||
|
data class ChatCharacterCalculateItem(
|
||||||
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
|
@JsonProperty("characterImage") val characterImage: String?,
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
||||||
|
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
||||||
|
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("settlementKrw") val settlementKrw: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (전체)
|
||||||
|
data class ChatCharacterCalculateResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Int,
|
||||||
|
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
||||||
|
val image = imagePurchaseCan ?: 0
|
||||||
|
val message = messagePurchaseCan ?: 0
|
||||||
|
val quota = quotaPurchaseCan ?: 0
|
||||||
|
val total = image + message + quota
|
||||||
|
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
||||||
|
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
||||||
|
|
||||||
|
return ChatCharacterCalculateItem(
|
||||||
|
characterId = characterId,
|
||||||
|
characterImage = characterImagePath,
|
||||||
|
name = characterName,
|
||||||
|
imagePurchaseCan = image,
|
||||||
|
messagePurchaseCan = message,
|
||||||
|
quotaPurchaseCan = quota,
|
||||||
|
totalCan = total,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
settlementKrw = settlement.toInt()
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,423 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminChatCharacterController(
|
||||||
|
private val service: ChatCharacterService,
|
||||||
|
private val adminService: AdminChatCharacterService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
|
||||||
|
@Value("\${weraser.api-key}")
|
||||||
|
private val apiKey: String,
|
||||||
|
|
||||||
|
@Value("\${weraser.api-url}")
|
||||||
|
private val apiUrl: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 캐릭터 목록 조회 API
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 페이징된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getCharacterList(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||||
|
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색(관리자)
|
||||||
|
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
||||||
|
* - 페이징 지원: page, size 파라미터 사용
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchCharacters(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||||
|
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
||||||
|
val response = ChatCharacterSearchListPageResponse(
|
||||||
|
totalCount = resultPage.totalElements,
|
||||||
|
content = resultPage.content
|
||||||
|
)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 상세 정보 조회 API
|
||||||
|
*
|
||||||
|
* @param characterId 캐릭터 ID
|
||||||
|
* @return 캐릭터 상세 정보
|
||||||
|
*/
|
||||||
|
@GetMapping("/{characterId}")
|
||||||
|
fun getCharacterDetail(
|
||||||
|
@PathVariable characterId: Long
|
||||||
|
) = run {
|
||||||
|
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun registerCharacter(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
||||||
|
|
||||||
|
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||||
|
val existingCharacter = service.findByName(request.name)
|
||||||
|
if (existingCharacter != null) {
|
||||||
|
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 외부 API 호출
|
||||||
|
val characterUUID = callExternalApi(request)
|
||||||
|
|
||||||
|
// 2. ChatCharacter 저장
|
||||||
|
val chatCharacter = service.createChatCharacterWithDetails(
|
||||||
|
characterUUID = characterUUID,
|
||||||
|
name = request.name,
|
||||||
|
description = request.description,
|
||||||
|
systemPrompt = request.systemPrompt,
|
||||||
|
age = request.age?.toIntOrNull(),
|
||||||
|
gender = request.gender,
|
||||||
|
mbti = request.mbti,
|
||||||
|
speechPattern = request.speechPattern,
|
||||||
|
speechStyle = request.speechStyle,
|
||||||
|
appearance = request.appearance,
|
||||||
|
originalTitle = request.originalTitle,
|
||||||
|
originalLink = request.originalLink,
|
||||||
|
characterType = request.characterType?.let {
|
||||||
|
runCatching { CharacterType.valueOf(it) }
|
||||||
|
.getOrDefault(CharacterType.Character)
|
||||||
|
} ?: CharacterType.Character,
|
||||||
|
tags = request.tags,
|
||||||
|
values = request.values,
|
||||||
|
hobbies = request.hobbies,
|
||||||
|
goals = request.goals,
|
||||||
|
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||||
|
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||||
|
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||||
|
relationships = request.relationships
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
||||||
|
val imagePath = saveImage(
|
||||||
|
characterId = chatCharacter.id!!,
|
||||||
|
image = image
|
||||||
|
)
|
||||||
|
chatCharacter.imagePath = imagePath
|
||||||
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
|
||||||
|
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
||||||
|
try {
|
||||||
|
val factory = SimpleClientHttpRequestFactory()
|
||||||
|
factory.setConnectTimeout(20000) // 20초
|
||||||
|
factory.setReadTimeout(20000) // 20초
|
||||||
|
|
||||||
|
val restTemplate = RestTemplate(factory)
|
||||||
|
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
|
||||||
|
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
||||||
|
val body = mutableMapOf<String, Any>()
|
||||||
|
body["name"] = request.name
|
||||||
|
body["systemPrompt"] = request.systemPrompt
|
||||||
|
body["description"] = request.description
|
||||||
|
request.age?.let { body["age"] = it }
|
||||||
|
request.gender?.let { body["gender"] = it }
|
||||||
|
request.mbti?.let { body["mbti"] = it }
|
||||||
|
request.speechPattern?.let { body["speechPattern"] = it }
|
||||||
|
request.speechStyle?.let { body["speechStyle"] = it }
|
||||||
|
request.appearance?.let { body["appearance"] = it }
|
||||||
|
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
||||||
|
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
||||||
|
if (request.values.isNotEmpty()) body["values"] = request.values
|
||||||
|
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
||||||
|
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
||||||
|
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
||||||
|
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
||||||
|
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
||||||
|
|
||||||
|
val httpEntity = HttpEntity(body, headers)
|
||||||
|
|
||||||
|
val response = restTemplate.exchange(
|
||||||
|
"$apiUrl/api/characters",
|
||||||
|
HttpMethod.POST,
|
||||||
|
httpEntity,
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 파싱
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||||
|
|
||||||
|
// success가 false이면 throw
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// success가 true이면 data.id 반환
|
||||||
|
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
|
||||||
|
// S3에 이미지 업로드
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 수정 API
|
||||||
|
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||||
|
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||||
|
* 3. 이미지 있는지 확인
|
||||||
|
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
||||||
|
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
||||||
|
*
|
||||||
|
* @param image 캐릭터 이미지 (선택적)
|
||||||
|
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
||||||
|
* @return ApiResponse 객체
|
||||||
|
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun updateCharacter(
|
||||||
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
||||||
|
|
||||||
|
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||||
|
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
||||||
|
|
||||||
|
// 3. 이미지 있는지 확인
|
||||||
|
val hasImage = image != null && !image.isEmpty
|
||||||
|
|
||||||
|
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
||||||
|
val hasDbOnlyChanges =
|
||||||
|
request.originalTitle != null ||
|
||||||
|
request.originalLink != null ||
|
||||||
|
request.characterType != null ||
|
||||||
|
request.originalWorkId != null
|
||||||
|
|
||||||
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
|
throw SodaException("변경된 데이터가 없습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||||
|
if (hasChangedData) {
|
||||||
|
val chatCharacter = service.findById(request.id)
|
||||||
|
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||||
|
|
||||||
|
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||||
|
if (request.name != null && request.name != chatCharacter.name) {
|
||||||
|
val existingCharacter = service.findByName(request.name)
|
||||||
|
if (existingCharacter != null) {
|
||||||
|
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 경로 변수 초기화
|
||||||
|
// 이미지가 있으면 이미지 저장
|
||||||
|
val imagePath = if (hasImage) {
|
||||||
|
saveImage(
|
||||||
|
characterId = request.id,
|
||||||
|
image = image!!
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티 수정
|
||||||
|
service.updateChatCharacterWithDetails(
|
||||||
|
imagePath = imagePath,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
|
||||||
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청에 변경된 데이터가 있는지 확인
|
||||||
|
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
||||||
|
*
|
||||||
|
* @param request 수정 요청 데이터
|
||||||
|
* @return 변경된 데이터가 있으면 true, 없으면 false
|
||||||
|
*/
|
||||||
|
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
||||||
|
return request.systemPrompt != null ||
|
||||||
|
request.description != null ||
|
||||||
|
request.age != null ||
|
||||||
|
request.gender != null ||
|
||||||
|
request.mbti != null ||
|
||||||
|
request.speechPattern != null ||
|
||||||
|
request.speechStyle != null ||
|
||||||
|
request.appearance != null ||
|
||||||
|
request.isActive != null ||
|
||||||
|
request.tags != null ||
|
||||||
|
request.hobbies != null ||
|
||||||
|
request.values != null ||
|
||||||
|
request.goals != null ||
|
||||||
|
request.relationships != null ||
|
||||||
|
request.personalities != null ||
|
||||||
|
request.backgrounds != null ||
|
||||||
|
request.memories != null ||
|
||||||
|
request.name != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 호출 - 수정 API
|
||||||
|
* 변경된 데이터만 요청에 포함
|
||||||
|
*
|
||||||
|
* @param characterUUID 캐릭터 UUID
|
||||||
|
* @param request 수정 요청 데이터
|
||||||
|
*/
|
||||||
|
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
||||||
|
try {
|
||||||
|
val factory = SimpleClientHttpRequestFactory()
|
||||||
|
factory.setConnectTimeout(20000) // 20초
|
||||||
|
factory.setReadTimeout(20000) // 20초
|
||||||
|
|
||||||
|
val restTemplate = RestTemplate(factory)
|
||||||
|
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.set("x-api-key", apiKey)
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
|
||||||
|
// 변경된 데이터만 포함하는 맵 생성
|
||||||
|
val updateData = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
|
// isActive = false인 경우 처리
|
||||||
|
if (request.isActive != null && !request.isActive) {
|
||||||
|
val inactiveName = "inactive_${request.name}"
|
||||||
|
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||||
|
updateData["name"] = inactiveName + randomSuffix
|
||||||
|
} else {
|
||||||
|
request.name?.let { updateData["name"] = it }
|
||||||
|
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
||||||
|
request.description?.let { updateData["description"] = it }
|
||||||
|
request.age?.let { updateData["age"] = it }
|
||||||
|
request.gender?.let { updateData["gender"] = it }
|
||||||
|
request.mbti?.let { updateData["mbti"] = it }
|
||||||
|
request.speechPattern?.let { updateData["speechPattern"] = it }
|
||||||
|
request.speechStyle?.let { updateData["speechStyle"] = it }
|
||||||
|
request.appearance?.let { updateData["appearance"] = it }
|
||||||
|
request.tags?.let { updateData["tags"] = it }
|
||||||
|
request.hobbies?.let { updateData["hobbies"] = it }
|
||||||
|
request.values?.let { updateData["values"] = it }
|
||||||
|
request.goals?.let { updateData["goals"] = it }
|
||||||
|
request.relationships?.let { updateData["relationships"] = it }
|
||||||
|
request.personalities?.let { updateData["personalities"] = it }
|
||||||
|
request.backgrounds?.let { updateData["backgrounds"] = it }
|
||||||
|
request.memories?.let { updateData["memories"] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val httpEntity = HttpEntity(updateData, headers)
|
||||||
|
val response = restTemplate.exchange(
|
||||||
|
"$apiUrl/api/characters/$characterUUID",
|
||||||
|
HttpMethod.PUT,
|
||||||
|
httpEntity,
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 파싱
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||||
|
|
||||||
|
// success가 false이면 throw
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,82 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character/curation")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class CharacterCurationAdminController(
|
||||||
|
private val service: CharacterCurationAdminService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||||
|
ApiResponse.ok(service.listAll())
|
||||||
|
|
||||||
|
@GetMapping("/{curationId}/characters")
|
||||||
|
fun listCharacters(
|
||||||
|
@PathVariable curationId: Long
|
||||||
|
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||||
|
val characters = service.listCharacters(curationId)
|
||||||
|
val items = characters.map {
|
||||||
|
CharacterCurationCharacterItemResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||||
|
ApiResponse.ok(service.register(request).id)
|
||||||
|
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.update(request).id)
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}")
|
||||||
|
fun delete(@PathVariable curationId: Long) =
|
||||||
|
ApiResponse.ok(service.softDelete(curationId))
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.reorder(request.ids))
|
||||||
|
|
||||||
|
@PostMapping("/{curationId}/characters")
|
||||||
|
fun addCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||||
|
): ApiResponse<Boolean> {
|
||||||
|
val ids = request.characterIds.filter { it > 0 }.distinct()
|
||||||
|
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||||
|
service.addCharacters(curationId, ids)
|
||||||
|
return ApiResponse.ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||||
|
fun removeCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@PathVariable characterId: Long
|
||||||
|
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||||
|
|
||||||
|
@PutMapping("/{curationId}/characters/reorder")
|
||||||
|
fun reorderCharacters(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||||
|
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||||
|
}
|
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
data class CharacterCurationRegisterRequest(
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean = false,
|
||||||
|
val isActive: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationUpdateRequest(
|
||||||
|
val id: Long,
|
||||||
|
val title: String? = null,
|
||||||
|
val isAdult: Boolean? = null,
|
||||||
|
val isActive: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationOrderUpdateRequest(
|
||||||
|
val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationAddCharacterRequest(
|
||||||
|
val characterIds: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationReorderCharactersRequest(
|
||||||
|
val characterIds: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationListItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val characterCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||||
|
// id, name, description, 이미지 URL
|
||||||
|
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||||
|
|
||||||
|
data class CharacterCurationCharacterItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val imageUrl: String
|
||||||
|
)
|
@@ -0,0 +1,153 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CharacterCurationAdminService(
|
||||||
|
private val curationRepository: CharacterCurationRepository,
|
||||||
|
private val mappingRepository: CharacterCurationMappingRepository,
|
||||||
|
private val characterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||||
|
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||||
|
val curation = CharacterCuration(
|
||||||
|
title = request.title,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isActive = request.isActive,
|
||||||
|
sortOrder = sortOrder
|
||||||
|
)
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||||
|
val curation = curationRepository.findById(request.id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||||
|
|
||||||
|
request.title?.let { curation.title = it }
|
||||||
|
request.isAdult?.let { curation.isAdult = it }
|
||||||
|
request.isActive?.let { curation.isActive = it }
|
||||||
|
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun softDelete(curationId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
curation.isActive = false
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorder(ids: List<Long>) {
|
||||||
|
ids.forEachIndexed { index, id ->
|
||||||
|
val curation = curationRepository.findById(id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||||
|
curation.sortOrder = index + 1
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
|
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||||
|
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||||
|
|
||||||
|
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||||
|
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
||||||
|
|
||||||
|
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||||
|
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||||
|
val characterMap = characters.associateBy { it.id!! }
|
||||||
|
|
||||||
|
// 조회 결과에 존재하는 캐릭터만 유효
|
||||||
|
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
||||||
|
|
||||||
|
val existingMappings = mappingRepository.findByCuration(curation)
|
||||||
|
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
||||||
|
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||||
|
|
||||||
|
val toSave = mutableListOf<CharacterCurationMapping>()
|
||||||
|
validIds.forEach { id ->
|
||||||
|
if (!existingCharacterIds.contains(id)) {
|
||||||
|
val character = characterMap[id] ?: return@forEach
|
||||||
|
toSave += CharacterCurationMapping(
|
||||||
|
curation = curation,
|
||||||
|
chatCharacter = character,
|
||||||
|
sortOrder = nextOrder++
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSave.isNotEmpty()) {
|
||||||
|
mappingRepository.saveAll(toSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||||
|
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||||
|
mappingRepository.delete(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||||
|
|
||||||
|
characterIds.forEachIndexed { index, cid ->
|
||||||
|
val mapping = mappingByCharacterId[cid]
|
||||||
|
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||||
|
mapping.sortOrder = index + 1
|
||||||
|
mappingRepository.save(mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||||
|
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||||
|
if (curations.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// DB 집계로 활성 캐릭터 수 카운트
|
||||||
|
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
||||||
|
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
||||||
|
|
||||||
|
return curations.map { curation ->
|
||||||
|
CharacterCurationListItemResponse(
|
||||||
|
id = curation.id!!,
|
||||||
|
title = curation.title,
|
||||||
|
isAdult = curation.isAdult,
|
||||||
|
isActive = curation.isActive,
|
||||||
|
characterCount = countByCurationId[curation.id!!] ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||||
|
return mappings.map { it.chatCharacter }
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,132 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 캐릭터 상세 응답 DTO
|
||||||
|
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||||
|
*/
|
||||||
|
data class ChatCharacterDetailResponse(
|
||||||
|
val id: Long,
|
||||||
|
val characterUUID: String,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val description: String,
|
||||||
|
val systemPrompt: String,
|
||||||
|
val characterType: String,
|
||||||
|
val age: Int?,
|
||||||
|
val gender: String?,
|
||||||
|
val mbti: String?,
|
||||||
|
val speechPattern: String?,
|
||||||
|
val speechStyle: String?,
|
||||||
|
val appearance: String?,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val tags: List<String>,
|
||||||
|
val hobbies: List<String>,
|
||||||
|
val values: List<String>,
|
||||||
|
val goals: List<String>,
|
||||||
|
val relationships: List<RelationshipResponse>,
|
||||||
|
val personalities: List<PersonalityResponse>,
|
||||||
|
val backgrounds: List<BackgroundResponse>,
|
||||||
|
val memories: List<MemoryResponse>,
|
||||||
|
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
|
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${chatCharacter.imagePath}"
|
||||||
|
} else {
|
||||||
|
chatCharacter.imagePath ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val ow = chatCharacter.originalWork
|
||||||
|
val originalWorkBrief = ow?.let {
|
||||||
|
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${it.imagePath}"
|
||||||
|
} else {
|
||||||
|
it.imagePath
|
||||||
|
}
|
||||||
|
OriginalWorkBriefResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
imageUrl = owImage,
|
||||||
|
title = it.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatCharacterDetailResponse(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
characterUUID = chatCharacter.characterUUID,
|
||||||
|
name = chatCharacter.name,
|
||||||
|
imageUrl = fullImagePath,
|
||||||
|
description = chatCharacter.description,
|
||||||
|
systemPrompt = chatCharacter.systemPrompt,
|
||||||
|
characterType = chatCharacter.characterType.name,
|
||||||
|
age = chatCharacter.age,
|
||||||
|
gender = chatCharacter.gender,
|
||||||
|
mbti = chatCharacter.mbti,
|
||||||
|
speechPattern = chatCharacter.speechPattern,
|
||||||
|
speechStyle = chatCharacter.speechStyle,
|
||||||
|
appearance = chatCharacter.appearance,
|
||||||
|
isActive = chatCharacter.isActive,
|
||||||
|
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||||
|
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
||||||
|
values = chatCharacter.valueMappings.map { it.value.value },
|
||||||
|
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
||||||
|
relationships = chatCharacter.relationships.map {
|
||||||
|
RelationshipResponse(
|
||||||
|
personName = it.personName,
|
||||||
|
relationshipName = it.relationshipName,
|
||||||
|
description = it.description,
|
||||||
|
importance = it.importance,
|
||||||
|
relationshipType = it.relationshipType,
|
||||||
|
currentStatus = it.currentStatus
|
||||||
|
)
|
||||||
|
},
|
||||||
|
personalities = chatCharacter.personalities.map {
|
||||||
|
PersonalityResponse(it.trait, it.description)
|
||||||
|
},
|
||||||
|
backgrounds = chatCharacter.backgrounds.map {
|
||||||
|
BackgroundResponse(it.topic, it.description)
|
||||||
|
},
|
||||||
|
memories = chatCharacter.memories.map {
|
||||||
|
MemoryResponse(it.title, it.content, it.emotion)
|
||||||
|
},
|
||||||
|
originalWork = originalWorkBrief
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PersonalityResponse(
|
||||||
|
val trait: String,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BackgroundResponse(
|
||||||
|
val topic: String,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemoryResponse(
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
val emotion: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RelationshipResponse(
|
||||||
|
val personName: String,
|
||||||
|
val relationshipName: String,
|
||||||
|
val description: String,
|
||||||
|
val importance: Int,
|
||||||
|
val relationshipType: String,
|
||||||
|
val currentStatus: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkBriefResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val title: String
|
||||||
|
)
|
@@ -0,0 +1,90 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class ChatCharacterPersonalityRequest(
|
||||||
|
@JsonProperty("trait") val trait: String,
|
||||||
|
@JsonProperty("description") val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterBackgroundRequest(
|
||||||
|
@JsonProperty("topic") val topic: String,
|
||||||
|
@JsonProperty("description") val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterMemoryRequest(
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("content") val content: String,
|
||||||
|
@JsonProperty("emotion") val emotion: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterRelationshipRequest(
|
||||||
|
@JsonProperty("personName") val personName: String,
|
||||||
|
@JsonProperty("relationshipName") val relationshipName: String,
|
||||||
|
@JsonProperty("description") val description: String,
|
||||||
|
@JsonProperty("importance") val importance: Int,
|
||||||
|
@JsonProperty("relationshipType") val relationshipType: String,
|
||||||
|
@JsonProperty("currentStatus") val currentStatus: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterRegisterRequest(
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("systemPrompt") val systemPrompt: String,
|
||||||
|
@JsonProperty("description") val description: String,
|
||||||
|
@JsonProperty("age") val age: String?,
|
||||||
|
@JsonProperty("gender") val gender: String?,
|
||||||
|
@JsonProperty("mbti") val mbti: String?,
|
||||||
|
@JsonProperty("speechPattern") val speechPattern: String?,
|
||||||
|
@JsonProperty("speechStyle") val speechStyle: String?,
|
||||||
|
@JsonProperty("appearance") val appearance: String?,
|
||||||
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||||
|
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||||
|
@JsonProperty("values") val values: List<String> = emptyList(),
|
||||||
|
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
||||||
|
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
||||||
|
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
||||||
|
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
||||||
|
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExternalApiResponse(
|
||||||
|
@JsonProperty("success") val success: Boolean,
|
||||||
|
@JsonProperty("data") val data: ExternalApiData? = null,
|
||||||
|
@JsonProperty("message") val message: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ExternalApiData(
|
||||||
|
@JsonProperty("id") val id: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterUpdateRequest(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("name") val name: String? = null,
|
||||||
|
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
||||||
|
@JsonProperty("description") val description: String? = null,
|
||||||
|
@JsonProperty("age") val age: String? = null,
|
||||||
|
@JsonProperty("gender") val gender: String? = null,
|
||||||
|
@JsonProperty("mbti") val mbti: String? = null,
|
||||||
|
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
||||||
|
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
||||||
|
@JsonProperty("appearance") val appearance: String? = null,
|
||||||
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
|
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null,
|
||||||
|
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
||||||
|
@JsonProperty("values") val values: List<String>? = null,
|
||||||
|
@JsonProperty("goals") val goals: List<String>? = null,
|
||||||
|
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||||
|
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||||
|
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||||
|
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
||||||
|
)
|
@@ -0,0 +1,62 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
data class ChatCharacterListResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val description: String,
|
||||||
|
val gender: String?,
|
||||||
|
val age: Int?,
|
||||||
|
val mbti: String?,
|
||||||
|
val speechStyle: String?,
|
||||||
|
val speechPattern: String?,
|
||||||
|
val tags: List<String>,
|
||||||
|
val createdAt: String?,
|
||||||
|
val updatedAt: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
||||||
|
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${chatCharacter.imagePath}"
|
||||||
|
} else {
|
||||||
|
chatCharacter.imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
||||||
|
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
||||||
|
?.withZoneSameInstant(seoulZoneId)
|
||||||
|
?.format(formatter)
|
||||||
|
|
||||||
|
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
||||||
|
?.withZoneSameInstant(seoulZoneId)
|
||||||
|
?.format(formatter)
|
||||||
|
|
||||||
|
return ChatCharacterListResponse(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
name = chatCharacter.name,
|
||||||
|
imageUrl = fullImagePath,
|
||||||
|
description = chatCharacter.description,
|
||||||
|
gender = chatCharacter.gender,
|
||||||
|
age = chatCharacter.age,
|
||||||
|
mbti = chatCharacter.mbti,
|
||||||
|
speechStyle = chatCharacter.speechStyle,
|
||||||
|
speechPattern = chatCharacter.speechPattern,
|
||||||
|
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||||
|
createdAt = createdAtStr,
|
||||||
|
updatedAt = updatedAtStr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChatCharacterListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterListResponse>
|
||||||
|
)
|
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterSearchListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterListResponse>
|
||||||
|
)
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkChatCharacterResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val imagePath: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||||
|
return OriginalWorkChatCharacterResponse(
|
||||||
|
id = character.id!!,
|
||||||
|
name = character.name,
|
||||||
|
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkChatCharacterListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<OriginalWorkChatCharacterResponse>
|
||||||
|
)
|
@@ -0,0 +1,170 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.image
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character/image")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminCharacterImageController(
|
||||||
|
private val imageService: CharacterImageService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val imageCloudFront: ImageContentCloudFront,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val freeBucket: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(@RequestParam characterId: Long) = run {
|
||||||
|
val expiration = 5L * 60L * 1000L // 5분
|
||||||
|
val list = imageService.listActiveByCharacter(characterId)
|
||||||
|
.map { img ->
|
||||||
|
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||||
|
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
||||||
|
}
|
||||||
|
ApiResponse.ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{imageId}")
|
||||||
|
fun detail(@PathVariable imageId: Long) = run {
|
||||||
|
val img = imageService.getById(imageId)
|
||||||
|
val expiration = 5L * 60L * 1000L // 5분
|
||||||
|
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||||
|
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
||||||
|
|
||||||
|
// 업로드 키 생성
|
||||||
|
val s3Key = buildS3Key(characterId = request.characterId)
|
||||||
|
|
||||||
|
// 원본 저장 (content-bucket)
|
||||||
|
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||||
|
|
||||||
|
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||||
|
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||||
|
|
||||||
|
imageService.registerImage(
|
||||||
|
characterId = request.characterId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
blurImagePath = blurImagePath,
|
||||||
|
imagePriceCan = request.imagePriceCan,
|
||||||
|
messagePriceCan = request.messagePriceCan,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
triggers = request.triggers ?: emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{imageId}/triggers")
|
||||||
|
fun updateTriggers(
|
||||||
|
@PathVariable imageId: Long,
|
||||||
|
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||||
|
) = run {
|
||||||
|
if (!request.triggers.isNullOrEmpty()) {
|
||||||
|
imageService.updateTriggers(imageId, request.triggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{imageId}")
|
||||||
|
fun delete(@PathVariable imageId: Long) = run {
|
||||||
|
imageService.deleteImage(imageId)
|
||||||
|
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||||
|
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
||||||
|
imageService.updateOrders(request.characterId, request.ids)
|
||||||
|
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildS3Key(characterId: Long): String {
|
||||||
|
val fileName = generateFileName("character-image")
|
||||||
|
return "characters/$characterId/images/$fileName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
// 멀티파트를 BufferedImage로 읽기
|
||||||
|
val bytes = image.bytes
|
||||||
|
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||||
|
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
||||||
|
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||||
|
|
||||||
|
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
val format = when (image.contentType?.lowercase()) {
|
||||||
|
"image/png" -> "png"
|
||||||
|
else -> "jpg"
|
||||||
|
}
|
||||||
|
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||||
|
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||||
|
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = baos.size().toLong()
|
||||||
|
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||||
|
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,53 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
|
|
||||||
|
// 요청 DTOs
|
||||||
|
|
||||||
|
data class RegisterCharacterImageRequest(
|
||||||
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
|
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||||
|
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||||
|
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateCharacterImageTriggersRequest(
|
||||||
|
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateCharacterImageOrdersRequest(
|
||||||
|
@JsonProperty("characterId") val characterId: Long?,
|
||||||
|
@JsonProperty("ids") val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTOs
|
||||||
|
|
||||||
|
data class AdminCharacterImageResponse(
|
||||||
|
val id: Long,
|
||||||
|
val characterId: Long,
|
||||||
|
val imagePriceCan: Long,
|
||||||
|
val messagePriceCan: Long,
|
||||||
|
val imageUrl: String,
|
||||||
|
val triggers: List<String>,
|
||||||
|
val isAdult: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
||||||
|
return base(entity, signedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
||||||
|
return AdminCharacterImageResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
characterId = entity.chatCharacter.id!!,
|
||||||
|
imagePriceCan = entity.imagePriceCan,
|
||||||
|
messagePriceCan = entity.messagePriceCan,
|
||||||
|
imageUrl = url,
|
||||||
|
triggers = entity.triggerMappings.map { it.tag.word },
|
||||||
|
isAdult = entity.isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,78 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.domain.Sort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChatCharacterService(
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 캐릭터 목록을 페이징하여 조회
|
||||||
|
*
|
||||||
|
* @param pageable 페이징 정보
|
||||||
|
* @return 페이징된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
||||||
|
// isActive가 true인 캐릭터만 조회
|
||||||
|
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
||||||
|
|
||||||
|
// 페이지 정보 생성
|
||||||
|
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||||
|
|
||||||
|
return ChatCharacterListPageResponse(
|
||||||
|
totalCount = page.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 페이지 요청 생성
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작)
|
||||||
|
* @param size 페이지 크기
|
||||||
|
* @return 페이지 요청 객체
|
||||||
|
*/
|
||||||
|
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
||||||
|
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 상세 정보 조회
|
||||||
|
*
|
||||||
|
* @param characterId 캐릭터 ID
|
||||||
|
* @param imageHost 이미지 호스트 URL
|
||||||
|
* @return 캐릭터 상세 정보
|
||||||
|
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
|
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
||||||
|
|
||||||
|
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchCharacters(
|
||||||
|
searchTerm: String,
|
||||||
|
pageable: Pageable,
|
||||||
|
imageHost: String = ""
|
||||||
|
): Page<ChatCharacterListResponse> {
|
||||||
|
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||||
|
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 등록 요청 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerRegisterRequest(
|
||||||
|
// 캐릭터 ID
|
||||||
|
@JsonProperty("characterId") val characterId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 수정 요청 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerUpdateRequest(
|
||||||
|
// 배너 ID
|
||||||
|
@JsonProperty("bannerId") val bannerId: Long,
|
||||||
|
|
||||||
|
// 캐릭터 ID (변경할 캐릭터)
|
||||||
|
@JsonProperty("characterId") val characterId: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
||||||
|
*/
|
||||||
|
data class UpdateBannerOrdersRequest(
|
||||||
|
// 배너 ID 목록 (순서대로 정렬됨)
|
||||||
|
@JsonProperty("ids") val ids: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imagePath: String,
|
||||||
|
val characterId: Long,
|
||||||
|
val characterName: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
||||||
|
return ChatCharacterBannerResponse(
|
||||||
|
id = banner.id!!,
|
||||||
|
imagePath = "$imageHost/${banner.imagePath}",
|
||||||
|
characterId = banner.chatCharacter.id!!,
|
||||||
|
characterName = banner.chatCharacter.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 목록 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterBannerResponse>
|
||||||
|
)
|
@@ -0,0 +1,199 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관리자 API
|
||||||
|
* - 원작 등록/수정/삭제
|
||||||
|
* - 원작과 캐릭터 연결(배정) 및 해제
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/original")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminOriginalWorkController(
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록
|
||||||
|
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
||||||
|
|
||||||
|
// 서비스 계층을 통해 원작을 생성
|
||||||
|
val saved = originalWorkService.createOriginalWork(request)
|
||||||
|
|
||||||
|
// 이미지 업로드 후 이미지 경로 업데이트
|
||||||
|
val imagePath = uploadImage(saved.id!!, image)
|
||||||
|
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정
|
||||||
|
* - 이미지가 있으면 교체, 없으면 유지
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(
|
||||||
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
||||||
|
|
||||||
|
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
||||||
|
val imagePath = if (image != null && !image.isEmpty) {
|
||||||
|
uploadImage(request.id, image)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
originalWorkService.updateOriginalWork(request, imagePath)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 삭제
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun delete(@PathVariable id: Long) = run {
|
||||||
|
originalWorkService.deleteOriginalWork(id)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 목록(페이징)
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 검색(관리자)
|
||||||
|
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
||||||
|
* - 페이징 제거: 전체 목록 반환
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun search(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String
|
||||||
|
) = run {
|
||||||
|
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||||
|
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun detail(@PathVariable id: Long) = run {
|
||||||
|
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에 기존 캐릭터들을 배정
|
||||||
|
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/assign-characters")
|
||||||
|
fun assignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.assignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에서 캐릭터들 해제
|
||||||
|
* - 캐릭터들의 originalWork를 null로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/unassign-characters")
|
||||||
|
fun unassignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.unassignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
||||||
|
* - 활성 캐릭터만 포함
|
||||||
|
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/characters")
|
||||||
|
fun listCharactersOfOriginal(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkChatCharacterListPageResponse(
|
||||||
|
totalCount = pageRes.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미지 업로드 공통 처리 */
|
||||||
|
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkRegisterRequest(
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("contentType") val contentType: String,
|
||||||
|
@JsonProperty("category") val category: String,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||||
|
@JsonProperty("description") val description: String = "",
|
||||||
|
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("writer") val writer: String? = null,
|
||||||
|
@JsonProperty("studio") val studio: String? = null,
|
||||||
|
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정 요청 DTO (부분 수정 가능)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkUpdateRequest(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("contentType") val contentType: String? = null,
|
||||||
|
@JsonProperty("category") val category: String? = null,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
||||||
|
@JsonProperty("description") val description: String? = null,
|
||||||
|
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("writer") val writer: String? = null,
|
||||||
|
@JsonProperty("studio") val studio: String? = null,
|
||||||
|
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세/목록 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val contentType: String,
|
||||||
|
val category: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val description: String,
|
||||||
|
val originalWork: String?,
|
||||||
|
val originalLink: String?,
|
||||||
|
val writer: String?,
|
||||||
|
val studio: String?,
|
||||||
|
val originalLinks: List<String>,
|
||||||
|
val tags: List<String>,
|
||||||
|
val imageUrl: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
||||||
|
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${entity.imagePath}"
|
||||||
|
} else {
|
||||||
|
entity.imagePath
|
||||||
|
}
|
||||||
|
return OriginalWorkResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
title = entity.title,
|
||||||
|
contentType = entity.contentType,
|
||||||
|
category = entity.category,
|
||||||
|
isAdult = entity.isAdult,
|
||||||
|
description = entity.description,
|
||||||
|
originalWork = entity.originalWork,
|
||||||
|
originalLink = entity.originalLink,
|
||||||
|
writer = entity.writer,
|
||||||
|
studio = entity.studio,
|
||||||
|
originalLinks = entity.originalLinks.map { it.url },
|
||||||
|
tags = entity.tagMappings.map { it.tag.tag },
|
||||||
|
imageUrl = fullImagePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OriginalWorkPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<OriginalWorkResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작-캐릭터 연결/해제 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkAssignCharactersRequest(
|
||||||
|
@JsonProperty("characterIds") val characterIds: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,213 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.data.domain.Sort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||||
|
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AdminOriginalWorkService(
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 원작 등록 (중복 제목 방지 포함) */
|
||||||
|
@Transactional
|
||||||
|
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||||
|
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||||
|
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
||||||
|
}
|
||||||
|
val entity = OriginalWork(
|
||||||
|
title = request.title,
|
||||||
|
contentType = request.contentType,
|
||||||
|
category = request.category,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
description = request.description,
|
||||||
|
originalWork = request.originalWork,
|
||||||
|
originalLink = request.originalLink,
|
||||||
|
writer = request.writer,
|
||||||
|
studio = request.studio
|
||||||
|
)
|
||||||
|
// 링크 리스트 생성
|
||||||
|
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
||||||
|
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
||||||
|
}
|
||||||
|
// 태그 매핑 생성 (기존 태그 재사용)
|
||||||
|
request.tags?.let { tags ->
|
||||||
|
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||||
|
normalized.forEach { t ->
|
||||||
|
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||||
|
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalWorkRepository.save(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
request.title?.let { ow.title = it }
|
||||||
|
request.contentType?.let { ow.contentType = it }
|
||||||
|
request.category?.let { ow.category = it }
|
||||||
|
request.isAdult?.let { ow.isAdult = it }
|
||||||
|
request.description?.let { ow.description = it }
|
||||||
|
request.originalWork?.let { ow.originalWork = it }
|
||||||
|
request.originalLink?.let { ow.originalLink = it }
|
||||||
|
request.writer?.let { ow.writer = it }
|
||||||
|
request.studio?.let { ow.studio = it }
|
||||||
|
// 링크 리스트가 전달되면 기존 것을 교체
|
||||||
|
request.originalLinks?.let { links ->
|
||||||
|
ow.originalLinks.clear()
|
||||||
|
links.filter { it.isNotBlank() }.forEach { link ->
|
||||||
|
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
||||||
|
request.tags?.let { tags ->
|
||||||
|
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||||
|
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
||||||
|
val toAdd = normalized.minus(current)
|
||||||
|
val toRemove = current.minus(normalized)
|
||||||
|
|
||||||
|
if (toRemove.isNotEmpty()) {
|
||||||
|
val itr = ow.tagMappings.iterator()
|
||||||
|
while (itr.hasNext()) {
|
||||||
|
val m = itr.next()
|
||||||
|
if (toRemove.contains(m.tag.tag)) {
|
||||||
|
itr.remove() // orphanRemoval=true로 매핑 삭제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toAdd.isNotEmpty()) {
|
||||||
|
toAdd.forEach { t ->
|
||||||
|
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||||
|
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imagePath != null) {
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
}
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 이미지 경로만 별도 갱신 */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 삭제 (소프트 삭제) */
|
||||||
|
@Transactional
|
||||||
|
fun deleteOriginalWork(id: Long) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||||
|
ow.isDeleted = true
|
||||||
|
originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWork(id: Long): OriginalWork {
|
||||||
|
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 페이징 조회 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||||
|
// 원작 존재 및 소프트 삭제 여부 확인
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||||
|
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에 기존 캐릭터들을 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = ow }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에서 캐릭터들 해제 */
|
||||||
|
@Transactional
|
||||||
|
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
// 원작 존재 확인 (소프트 삭제 제외)
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = null }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||||
|
val character = chatCharacterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
if (originalWorkId == 0L) {
|
||||||
|
character.originalWork = null
|
||||||
|
} else {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
character.originalWork = ow
|
||||||
|
}
|
||||||
|
|
||||||
|
chatCharacterRepository.save(character)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,56 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/audio-content")
|
||||||
|
class AdminContentController(private val service: AdminContentService) {
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getAudioContentList(
|
||||||
|
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getAudioContentList(
|
||||||
|
status = status ?: ContentReleaseStatus.OPEN,
|
||||||
|
pageable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchAudioContent(
|
||||||
|
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||||
|
@RequestParam(value = "search_word") searchWord: String,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.searchAudioContent(
|
||||||
|
status = status ?: ContentReleaseStatus.OPEN,
|
||||||
|
searchWord,
|
||||||
|
pageable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun modifyAudioContent(
|
||||||
|
@RequestBody request: UpdateAdminContentRequest
|
||||||
|
) = ApiResponse.ok(service.updateAudioContent(request))
|
||||||
|
|
||||||
|
@GetMapping("/main/tab")
|
||||||
|
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ContentReleaseStatus {
|
||||||
|
// 콘텐츠가 공개된 상태
|
||||||
|
OPEN,
|
||||||
|
|
||||||
|
// 예약된 콘텐츠, 아직 공개되지 않은 상태
|
||||||
|
SCHEDULED
|
||||||
|
}
|
@@ -0,0 +1,174 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioContentHashTag
|
||||||
|
import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface AdminContentRepository : JpaRepository<AudioContent, Long>, AdminAudioContentQueryRepository
|
||||||
|
|
||||||
|
interface AdminAudioContentQueryRepository {
|
||||||
|
fun getAudioContentTotalCount(
|
||||||
|
searchWord: String = "",
|
||||||
|
status: ContentReleaseStatus = ContentReleaseStatus.OPEN
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun getAudioContentList(
|
||||||
|
status: ContentReleaseStatus = ContentReleaseStatus.OPEN,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long,
|
||||||
|
searchWord: String = ""
|
||||||
|
): List<GetAdminContentListItem>
|
||||||
|
|
||||||
|
fun getHashTagList(audioContentId: Long): List<String>
|
||||||
|
fun findByIdAndActiveTrue(audioContentId: Long): AudioContent?
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminAudioContentQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) : AdminAudioContentQueryRepository {
|
||||||
|
override fun getAudioContentTotalCount(
|
||||||
|
searchWord: String,
|
||||||
|
status: ContentReleaseStatus
|
||||||
|
): Int {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
|
||||||
|
var where = audioContent.duration.isNotNull
|
||||||
|
.and(audioContent.member.isNotNull)
|
||||||
|
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
|
||||||
|
|
||||||
|
if (searchWord.trim().length > 1) {
|
||||||
|
where = where.and(
|
||||||
|
audioContent.title.contains(searchWord)
|
||||||
|
.or(audioContent.member.nickname.contains(searchWord))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
where = if (status == ContentReleaseStatus.SCHEDULED) {
|
||||||
|
where.and(audioContent.releaseDate.after(now))
|
||||||
|
} else {
|
||||||
|
where.and(audioContent.releaseDate.before(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(audioContent)
|
||||||
|
.where(where)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAudioContentList(
|
||||||
|
status: ContentReleaseStatus,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long,
|
||||||
|
searchWord: String
|
||||||
|
): List<GetAdminContentListItem> {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
|
||||||
|
var where = audioContent.duration.isNotNull
|
||||||
|
.and(audioContent.member.isNotNull)
|
||||||
|
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
|
||||||
|
|
||||||
|
if (searchWord.trim().length > 1) {
|
||||||
|
where = where.and(
|
||||||
|
audioContent.title.contains(searchWord)
|
||||||
|
.or(audioContent.member.nickname.contains(searchWord))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
where = if (status == ContentReleaseStatus.SCHEDULED) {
|
||||||
|
where.and(audioContent.releaseDate.after(now))
|
||||||
|
} else {
|
||||||
|
where.and(audioContent.releaseDate.before(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminContentListItem(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.detail,
|
||||||
|
audioContentCuration.title,
|
||||||
|
audioContentCuration.id.nullif(0),
|
||||||
|
audioContent.coverImage.prepend("/").prepend(imageHost),
|
||||||
|
audioContent.member!!.nickname,
|
||||||
|
audioContentTheme.theme,
|
||||||
|
audioContentTheme.id,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.limited,
|
||||||
|
audioContent.remaining,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.duration,
|
||||||
|
audioContent.content,
|
||||||
|
audioContent.isCommentAvailable,
|
||||||
|
formattedDateExpression(audioContent.createdAt),
|
||||||
|
formattedDateExpression(audioContent.releaseDate, "%Y-%m-%d %H:%i")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContent)
|
||||||
|
.leftJoin(audioContent.curation, audioContentCuration)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
.where(where)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy(audioContent.releaseDate.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getHashTagList(audioContentId: Long): List<String> {
|
||||||
|
return queryFactory
|
||||||
|
.select(hashTag.tag)
|
||||||
|
.from(audioContentHashTag)
|
||||||
|
.innerJoin(audioContentHashTag.hashTag, hashTag)
|
||||||
|
.innerJoin(audioContentHashTag.audioContent, audioContent)
|
||||||
|
.where(
|
||||||
|
audioContent.duration.isNotNull
|
||||||
|
.and(audioContent.member.isNotNull)
|
||||||
|
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||||
|
.and(audioContentHashTag.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByIdAndActiveTrue(audioContentId: Long): AudioContent? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContent)
|
||||||
|
.where(
|
||||||
|
audioContent.id.eq(audioContentId),
|
||||||
|
audioContent.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formattedDateExpression(
|
||||||
|
dateTime: DateTimePath<LocalDateTime>,
|
||||||
|
format: String = "%Y-%m-%d"
|
||||||
|
): StringTemplate {
|
||||||
|
return Expressions.stringTemplate(
|
||||||
|
"DATE_FORMAT({0}, {1})",
|
||||||
|
Expressions.dateTimeTemplate(
|
||||||
|
LocalDateTime::class.java,
|
||||||
|
"CONVERT_TZ({0},{1},{2})",
|
||||||
|
dateTime,
|
||||||
|
"UTC",
|
||||||
|
"Asia/Seoul"
|
||||||
|
),
|
||||||
|
format
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,128 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminContentService(
|
||||||
|
private val repository: AdminContentRepository,
|
||||||
|
private val themeRepository: AdminContentThemeRepository,
|
||||||
|
private val audioContentCloudFront: AudioContentCloudFront,
|
||||||
|
private val curationRepository: AdminContentCurationRepository,
|
||||||
|
private val contentMainTabRepository: AdminContentMainTabRepository
|
||||||
|
) {
|
||||||
|
fun getAudioContentList(status: ContentReleaseStatus, pageable: Pageable): GetAdminContentListResponse {
|
||||||
|
val totalCount = repository.getAudioContentTotalCount(status = status)
|
||||||
|
val audioContentAndThemeList = repository.getAudioContentList(
|
||||||
|
status = status,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
val audioContentList = audioContentAndThemeList
|
||||||
|
.map {
|
||||||
|
val tags = repository
|
||||||
|
.getHashTagList(audioContentId = it.audioContentId)
|
||||||
|
.joinToString(" ") { tag -> tag }
|
||||||
|
it.tags = tags
|
||||||
|
it
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
it.contentUrl = audioContentCloudFront.generateSignedURL(
|
||||||
|
resourcePath = it.contentUrl,
|
||||||
|
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
|
||||||
|
)
|
||||||
|
it
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetAdminContentListResponse(totalCount, audioContentList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchAudioContent(
|
||||||
|
status: ContentReleaseStatus,
|
||||||
|
searchWord: String,
|
||||||
|
pageable: Pageable
|
||||||
|
): GetAdminContentListResponse {
|
||||||
|
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||||
|
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
||||||
|
val audioContentAndThemeList = repository.getAudioContentList(
|
||||||
|
status = status,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong(),
|
||||||
|
searchWord = searchWord
|
||||||
|
)
|
||||||
|
|
||||||
|
val audioContentList = audioContentAndThemeList
|
||||||
|
.map {
|
||||||
|
val tags = repository
|
||||||
|
.getHashTagList(audioContentId = it.audioContentId)
|
||||||
|
.joinToString(" ") { tag -> tag }
|
||||||
|
it.tags = tags
|
||||||
|
it
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
it.contentUrl = audioContentCloudFront.generateSignedURL(
|
||||||
|
resourcePath = it.contentUrl,
|
||||||
|
expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2)
|
||||||
|
)
|
||||||
|
it
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetAdminContentListResponse(totalCount, audioContentList)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
||||||
|
val audioContent = repository.findByIdOrNull(id = request.id)
|
||||||
|
?: throw SodaException("없는 콘텐츠 입니다.")
|
||||||
|
|
||||||
|
if (request.isDefaultCoverImage) {
|
||||||
|
audioContent.coverImage = "`profile/default_profile.png`"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
if (!request.isActive) {
|
||||||
|
audioContent.releaseDate = null
|
||||||
|
}
|
||||||
|
audioContent.isActive = request.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isAdult != null) {
|
||||||
|
audioContent.isAdult = request.isAdult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isCommentAvailable != null) {
|
||||||
|
audioContent.isCommentAvailable = request.isCommentAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.title != null) {
|
||||||
|
audioContent.title = request.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.detail != null) {
|
||||||
|
audioContent.detail = request.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.curationId != null) {
|
||||||
|
val curation = curationRepository.findByIdAndActive(id = request.curationId)
|
||||||
|
audioContent.curation = curation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.themeId != null) {
|
||||||
|
val theme = themeRepository.findByIdAndActive(id = request.themeId)
|
||||||
|
audioContent.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContentMainTabList(): List<GetContentMainTabItem> {
|
||||||
|
return contentMainTabRepository.findAllByActiveIsTrue()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetAdminContentListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetAdminContentListItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAdminContentListItem @QueryProjection constructor(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val detail: String,
|
||||||
|
val curationTitle: String?,
|
||||||
|
val curationId: Long,
|
||||||
|
var coverImageUrl: String,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val theme: String,
|
||||||
|
val themeId: Long,
|
||||||
|
val price: Int,
|
||||||
|
val totalContentCount: Int?,
|
||||||
|
val remainingContentCount: Int?,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val remainingTime: String,
|
||||||
|
var contentUrl: String,
|
||||||
|
val isCommentAvailable: Boolean,
|
||||||
|
val date: String,
|
||||||
|
val releaseDate: String?
|
||||||
|
) {
|
||||||
|
var tags: String = ""
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
data class UpdateAdminContentRequest(
|
||||||
|
val id: Long,
|
||||||
|
val isDefaultCoverImage: Boolean,
|
||||||
|
val title: String?,
|
||||||
|
val detail: String?,
|
||||||
|
val curationId: Long?,
|
||||||
|
val themeId: Long?,
|
||||||
|
val isAdult: Boolean?,
|
||||||
|
val isActive: Boolean?,
|
||||||
|
val isCommentAvailable: Boolean?
|
||||||
|
)
|
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/audio-content/banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminContentBannerController(private val service: AdminContentBannerService) {
|
||||||
|
@PostMapping
|
||||||
|
fun createAudioContentMainBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.createAudioContentMainBanner(image, requestString))
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun modifyAudioContentMainBanner(
|
||||||
|
@RequestPart("image", required = false) image: MultipartFile? = null,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = ApiResponse.ok(service.updateAudioContentMainBanner(image, requestString))
|
||||||
|
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateBannerOrders(
|
||||||
|
@RequestBody request: UpdateBannerOrdersRequest
|
||||||
|
) = ApiResponse.ok(service.updateBannerOrders(request.ids), "수정되었습니다.")
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAudioContentMainBannerList(
|
||||||
|
@RequestParam(value = "tabId", required = false) tabId: Long? = null
|
||||||
|
) = ApiResponse.ok(service.getAudioContentMainBannerList(tabId = tabId))
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface AdminContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AdminContentBannerQueryRepository
|
||||||
|
|
||||||
|
interface AdminContentBannerQueryRepository {
|
||||||
|
fun getAudioContentMainBannerList(tabId: Long = 1): List<GetAdminContentBannerResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminContentBannerQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) : AdminContentBannerQueryRepository {
|
||||||
|
override fun getAudioContentMainBannerList(tabId: Long): List<GetAdminContentBannerResponse> {
|
||||||
|
var where = audioContentBanner.isActive.isTrue
|
||||||
|
|
||||||
|
where = if (tabId <= 1L) {
|
||||||
|
where.and(audioContentMainTab.id.isNull)
|
||||||
|
} else {
|
||||||
|
where.and(audioContentMainTab.id.eq(tabId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminContentBannerResponse(
|
||||||
|
audioContentBanner.id,
|
||||||
|
audioContentBanner.tab.id.coalesce(1),
|
||||||
|
audioContentBanner.type,
|
||||||
|
audioContentBanner.thumbnailImage.prepend("/").prepend(cloudFrontHost),
|
||||||
|
audioContentBanner.event.id,
|
||||||
|
audioContentBanner.event.thumbnailImage,
|
||||||
|
audioContentBanner.creator.id,
|
||||||
|
audioContentBanner.creator.nickname,
|
||||||
|
audioContentBanner.series.id,
|
||||||
|
audioContentBanner.series.title,
|
||||||
|
audioContentBanner.link,
|
||||||
|
audioContentBanner.isAdult
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContentBanner)
|
||||||
|
.leftJoin(audioContentBanner.event, event)
|
||||||
|
.leftJoin(audioContentBanner.creator, member)
|
||||||
|
.leftJoin(audioContentBanner.series, series)
|
||||||
|
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(audioContentBanner.orders.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,188 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.event.EventRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminContentBannerService(
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val repository: AdminContentBannerRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val seriesRepository: AdminContentSeriesRepository,
|
||||||
|
private val eventRepository: EventRepository,
|
||||||
|
private val contentMainTabRepository: AdminContentMainTabRepository,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val bucket: String
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
||||||
|
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
||||||
|
throw SodaException("크리에이터를 선택하세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
||||||
|
throw SodaException("시리즈를 선택하세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type == AudioContentBannerType.LINK && request.link == null) {
|
||||||
|
throw SodaException("링크 url을 입력하세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
|
||||||
|
throw SodaException("이벤트를 선택하세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val event = if (request.eventId != null && request.eventId > 0) {
|
||||||
|
eventRepository.findByIdOrNull(request.eventId)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val creator = if (request.creatorId != null && request.creatorId > 0) {
|
||||||
|
memberRepository.findByIdOrNull(request.creatorId)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val series = if (request.seriesId != null && request.seriesId > 0) {
|
||||||
|
seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val tab = if (request.tabId !== null) {
|
||||||
|
contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioContentBanner = AudioContentBanner(type = request.type)
|
||||||
|
audioContentBanner.link = request.link
|
||||||
|
audioContentBanner.isAdult = request.isAdult
|
||||||
|
audioContentBanner.event = event
|
||||||
|
audioContentBanner.creator = creator
|
||||||
|
audioContentBanner.series = series
|
||||||
|
audioContentBanner.tab = tab
|
||||||
|
repository.save(audioContentBanner)
|
||||||
|
|
||||||
|
val fileName = generateFileName()
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
|
||||||
|
)
|
||||||
|
audioContentBanner.thumbnailImage = imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
||||||
|
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
||||||
|
val audioContentBanner = repository.findByIdOrNull(request.id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
val fileName = generateFileName()
|
||||||
|
val imagePath = s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "audio_content_banner/${audioContentBanner.id}/$fileName"
|
||||||
|
)
|
||||||
|
audioContentBanner.thumbnailImage = imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isAdult != null) {
|
||||||
|
audioContentBanner.isAdult = request.isAdult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
audioContentBanner.isActive = request.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type != null) {
|
||||||
|
audioContentBanner.creator = null
|
||||||
|
audioContentBanner.event = null
|
||||||
|
audioContentBanner.link = null
|
||||||
|
audioContentBanner.series = null
|
||||||
|
|
||||||
|
when (request.type) {
|
||||||
|
AudioContentBannerType.EVENT -> {
|
||||||
|
if (request.eventId != null) {
|
||||||
|
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||||
|
?: throw SodaException("이벤트를 선택하세요.")
|
||||||
|
|
||||||
|
audioContentBanner.event = event
|
||||||
|
} else {
|
||||||
|
throw SodaException("이벤트를 선택하세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContentBannerType.CREATOR -> {
|
||||||
|
if (request.creatorId != null) {
|
||||||
|
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||||
|
?: throw SodaException("크리에이터를 선택하세요.")
|
||||||
|
|
||||||
|
audioContentBanner.creator = creator
|
||||||
|
} else {
|
||||||
|
throw SodaException("크리에이터를 선택하세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContentBannerType.LINK -> {
|
||||||
|
if (request.link != null) {
|
||||||
|
audioContentBanner.link = request.link
|
||||||
|
} else {
|
||||||
|
throw SodaException("링크 url을 입력하세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContentBannerType.SERIES -> {
|
||||||
|
if (request.seriesId != null) {
|
||||||
|
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
|
?: throw SodaException("시리즈를 선택하세요.")
|
||||||
|
|
||||||
|
audioContentBanner.series = series
|
||||||
|
} else {
|
||||||
|
throw SodaException("시리즈를 선택하세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContentBanner.type = request.type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.tabId !== null) {
|
||||||
|
audioContentBanner.tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateBannerOrders(ids: List<Long>) {
|
||||||
|
for (index in ids.indices) {
|
||||||
|
val tag = repository.findByIdOrNull(ids[index])
|
||||||
|
|
||||||
|
if (tag != null) {
|
||||||
|
tag.orders = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
|
||||||
|
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
|
||||||
|
data class CreateContentBannerRequest(
|
||||||
|
val type: AudioContentBannerType,
|
||||||
|
val tabId: Long?,
|
||||||
|
val eventId: Long?,
|
||||||
|
val creatorId: Long?,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val link: String?,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
|
||||||
|
data class GetAdminContentBannerResponse @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val tabId: Long?,
|
||||||
|
val type: AudioContentBannerType,
|
||||||
|
val thumbnailImageUrl: String,
|
||||||
|
val eventId: Long?,
|
||||||
|
val eventThumbnailImage: String?,
|
||||||
|
val creatorId: Long?,
|
||||||
|
val creatorNickname: String?,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val seriesTitle: String?,
|
||||||
|
val link: String?,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
data class UpdateBannerOrdersRequest(
|
||||||
|
val ids: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.banner
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
|
||||||
|
data class UpdateContentBannerRequest(
|
||||||
|
val id: Long,
|
||||||
|
val type: AudioContentBannerType?,
|
||||||
|
val tabId: Long?,
|
||||||
|
val eventId: Long?,
|
||||||
|
val creatorId: Long?,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val link: String?,
|
||||||
|
val isAdult: Boolean?,
|
||||||
|
val isActive: Boolean?
|
||||||
|
)
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
data class AddItemToCurationRequest(
|
||||||
|
val curationId: Long,
|
||||||
|
val itemIdList: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/audio-content/curation")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminContentCurationController(private val service: AdminContentCurationService) {
|
||||||
|
@PostMapping
|
||||||
|
fun createContentCuration(
|
||||||
|
@RequestBody request: CreateContentCurationRequest
|
||||||
|
) = ApiResponse.ok(service.createContentCuration(request))
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun updateContentCuration(
|
||||||
|
@RequestBody request: UpdateContentCurationRequest
|
||||||
|
) = ApiResponse.ok(service.updateContentCuration(request))
|
||||||
|
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateContentCurationOrders(
|
||||||
|
@RequestBody request: UpdateContentCurationOrdersRequest
|
||||||
|
) = ApiResponse.ok(service.updateContentCurationOrders(request.ids), "수정되었습니다.")
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getContentCurationList(
|
||||||
|
@RequestParam tabId: Long
|
||||||
|
) = ApiResponse.ok(service.getContentCurationList(tabId = tabId))
|
||||||
|
|
||||||
|
@GetMapping("/items")
|
||||||
|
fun getCurationItems(
|
||||||
|
@RequestParam curationId: Long
|
||||||
|
) = ApiResponse.ok(service.getCurationItem(curationId = curationId))
|
||||||
|
|
||||||
|
@GetMapping("/search/content")
|
||||||
|
fun searchCurationContentItem(
|
||||||
|
@RequestParam curationId: Long,
|
||||||
|
@RequestParam searchWord: String
|
||||||
|
) = ApiResponse.ok(service.searchCurationContentItem(curationId, searchWord))
|
||||||
|
|
||||||
|
@GetMapping("/search/series")
|
||||||
|
fun searchCurationSeriesItem(
|
||||||
|
@RequestParam curationId: Long,
|
||||||
|
@RequestParam searchWord: String
|
||||||
|
) = ApiResponse.ok(service.searchCurationSeriesItem(curationId, searchWord))
|
||||||
|
|
||||||
|
@PostMapping("/add/item")
|
||||||
|
fun addItemToCuration(
|
||||||
|
@RequestBody request: AddItemToCurationRequest
|
||||||
|
) = ApiResponse.ok(service.addItemToCuration(request), "큐레이션 아이템을 등록했습니다.")
|
||||||
|
|
||||||
|
@PutMapping("/remove/item")
|
||||||
|
fun removeItemInCuration(
|
||||||
|
@RequestBody request: RemoveItemInCurationRequest
|
||||||
|
) = ApiResponse.ok(service.removeItemInCuration(request), "큐레이션 아이템을 제거했습니다.")
|
||||||
|
|
||||||
|
@PutMapping("/orders/item")
|
||||||
|
fun updateItemInCurationOrders(
|
||||||
|
@RequestBody request: UpdateCurationItemOrdersRequest
|
||||||
|
) = ApiResponse.ok(service.updateItemInCurationOrders(request), "수정되었습니다.")
|
||||||
|
}
|
@@ -0,0 +1,106 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AdminContentCurationItemRepository :
|
||||||
|
JpaRepository<AudioContentCurationItem, Long>,
|
||||||
|
AdminContentCurationItemQueryRepository
|
||||||
|
|
||||||
|
interface AdminContentCurationItemQueryRepository {
|
||||||
|
fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem?
|
||||||
|
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem?
|
||||||
|
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem?
|
||||||
|
fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse>
|
||||||
|
fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminContentCurationItemQueryRepositoryImpl(
|
||||||
|
val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) : AdminContentCurationItemQueryRepository {
|
||||||
|
override fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContentCurationItem)
|
||||||
|
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||||
|
.innerJoin(audioContentCurationItem.series, series)
|
||||||
|
.where(
|
||||||
|
audioContentCurationItem.curation.id.eq(curationId),
|
||||||
|
audioContentCurationItem.series.id.eq(seriesId)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContentCurationItem)
|
||||||
|
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||||
|
.innerJoin(audioContentCurationItem.content, audioContent)
|
||||||
|
.where(
|
||||||
|
audioContentCurationItem.curation.id.eq(curationId),
|
||||||
|
audioContentCurationItem.content.id.eq(contentId)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem? {
|
||||||
|
return queryFactory.selectFrom(audioContentCurationItem)
|
||||||
|
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||||
|
.where(audioContentCuration.id.eq(curationId), audioContentCurationItem.id.eq(itemId))
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCurationItemResponse(
|
||||||
|
audioContentCurationItem.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.detail,
|
||||||
|
audioContent.coverImage.prepend("/").prepend(imageHost),
|
||||||
|
audioContent.member.nickname.coalesce(""),
|
||||||
|
audioContent.isAdult
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContentCurationItem)
|
||||||
|
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||||
|
.innerJoin(audioContentCurationItem.content, audioContent)
|
||||||
|
.where(
|
||||||
|
audioContentCuration.id.eq(curationId),
|
||||||
|
audioContentCurationItem.isActive.isTrue
|
||||||
|
)
|
||||||
|
.orderBy(audioContentCurationItem.orders.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetCurationItemResponse(
|
||||||
|
audioContentCurationItem.id,
|
||||||
|
series.title,
|
||||||
|
series.introduction,
|
||||||
|
series.coverImage.prepend("/").prepend(imageHost),
|
||||||
|
series.member.nickname.coalesce(""),
|
||||||
|
series.isAdult
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContentCurationItem)
|
||||||
|
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||||
|
.innerJoin(audioContentCurationItem.series, series)
|
||||||
|
.where(
|
||||||
|
audioContentCuration.id.eq(curationId),
|
||||||
|
audioContentCurationItem.isActive.isTrue
|
||||||
|
)
|
||||||
|
.orderBy(audioContentCurationItem.orders.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface AdminContentCurationRepository :
|
||||||
|
JpaRepository<AudioContentCuration, Long>,
|
||||||
|
AdminContentCurationQueryRepository
|
||||||
|
|
||||||
|
interface AdminContentCurationQueryRepository {
|
||||||
|
fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse>
|
||||||
|
fun findByIdAndActive(id: Long): AudioContentCuration?
|
||||||
|
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
|
||||||
|
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminContentCurationQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) : AdminContentCurationQueryRepository {
|
||||||
|
override fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminContentCurationResponse(
|
||||||
|
audioContentCuration.id,
|
||||||
|
audioContentMainTab.id,
|
||||||
|
audioContentCuration.title,
|
||||||
|
audioContentCuration.description,
|
||||||
|
audioContentCuration.isAdult,
|
||||||
|
audioContentCuration.isSeries
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContentCuration)
|
||||||
|
.innerJoin(audioContentCuration.tab, audioContentMainTab)
|
||||||
|
.where(
|
||||||
|
audioContentCuration.isActive.isTrue,
|
||||||
|
audioContentMainTab.id.eq(tabId)
|
||||||
|
)
|
||||||
|
.orderBy(audioContentCuration.orders.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByIdAndActive(id: Long): AudioContentCuration? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContentCuration)
|
||||||
|
.where(
|
||||||
|
audioContentCuration.id.eq(id)
|
||||||
|
.and(audioContentCuration.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchCurationContentItem(
|
||||||
|
curationId: Long,
|
||||||
|
searchWord: String
|
||||||
|
): List<SearchCurationItemResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QSearchCurationItemResponse(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.coverImage.prepend("/").prepend(imageHost)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContent)
|
||||||
|
.leftJoin(audioContentCurationItem)
|
||||||
|
.on(
|
||||||
|
audioContent.id.eq(audioContentCurationItem.content.id)
|
||||||
|
.and(audioContentCurationItem.curation.id.eq(curationId))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
audioContent.duration.isNotNull
|
||||||
|
.and(audioContent.member.isNotNull)
|
||||||
|
.and(audioContent.isActive.isTrue)
|
||||||
|
.and(audioContent.title.contains(searchWord))
|
||||||
|
.and(audioContentCurationItem.id.isNull)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchCurationSeriesItem(
|
||||||
|
curationId: Long,
|
||||||
|
searchWord: String
|
||||||
|
): List<SearchCurationItemResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QSearchCurationItemResponse(
|
||||||
|
series.id,
|
||||||
|
series.title,
|
||||||
|
series.coverImage.prepend("/").prepend(imageHost)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(series)
|
||||||
|
.leftJoin(audioContentCurationItem)
|
||||||
|
.on(
|
||||||
|
series.id.eq(audioContentCurationItem.series.id)
|
||||||
|
.and(audioContentCurationItem.curation.id.eq(curationId))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
series.isActive.isTrue
|
||||||
|
.and(series.member.isNotNull)
|
||||||
|
.and(series.title.contains(searchWord))
|
||||||
|
.and(
|
||||||
|
audioContentCurationItem.id.isNull
|
||||||
|
.or(audioContentCurationItem.isActive.isFalse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,168 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.AdminContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminContentCurationService(
|
||||||
|
private val repository: AdminContentCurationRepository,
|
||||||
|
private val contentMainTabRepository: AdminContentMainTabRepository,
|
||||||
|
private val seriesRepository: AdminContentSeriesRepository,
|
||||||
|
private val contentRepository: AdminContentRepository,
|
||||||
|
private val contentCurationItemRepository: AdminContentCurationItemRepository
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createContentCuration(request: CreateContentCurationRequest) {
|
||||||
|
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
val curation = AudioContentCuration(
|
||||||
|
title = request.title,
|
||||||
|
description = request.description,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isSeries = request.isSeries
|
||||||
|
)
|
||||||
|
curation.tab = tab
|
||||||
|
|
||||||
|
repository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
||||||
|
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
if (request.title != null) {
|
||||||
|
audioContentCuration.title = request.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.description != null) {
|
||||||
|
audioContentCuration.description = request.description
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isAdult != null) {
|
||||||
|
audioContentCuration.isAdult = request.isAdult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
audioContentCuration.isActive = request.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isSeries != null) {
|
||||||
|
audioContentCuration.isSeries = request.isSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.tabId != null) {
|
||||||
|
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
|
|
||||||
|
if (tab != null) {
|
||||||
|
audioContentCuration.tab = tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateContentCurationOrders(ids: List<Long>) {
|
||||||
|
for (index in ids.indices) {
|
||||||
|
val audioContentCuration = repository.findByIdOrNull(ids[index])
|
||||||
|
|
||||||
|
if (audioContentCuration != null) {
|
||||||
|
audioContentCuration.orders = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
|
||||||
|
return repository.getAudioContentCurationList(tabId = tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
||||||
|
val curation = repository.findByIdOrNull(curationId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
return if (curation.isSeries) {
|
||||||
|
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
||||||
|
} else {
|
||||||
|
contentCurationItemRepository.getAudioContentCurationItemList(curationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
|
||||||
|
return repository.searchCurationContentItem(curationId, searchWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
|
||||||
|
return repository.searchCurationSeriesItem(curationId, searchWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun addItemToCuration(request: AddItemToCurationRequest) {
|
||||||
|
// 큐레이션 조회
|
||||||
|
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
if (audioContentCuration.isSeries) {
|
||||||
|
request.itemIdList.forEach { seriesId ->
|
||||||
|
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
|
||||||
|
|
||||||
|
if (series != null) {
|
||||||
|
val item = contentCurationItemRepository.findByCurationIdAndSeriesId(
|
||||||
|
curationId = request.curationId,
|
||||||
|
seriesId = series.id
|
||||||
|
) ?: AudioContentCurationItem()
|
||||||
|
item.curation = audioContentCuration
|
||||||
|
item.series = series
|
||||||
|
item.isActive = true
|
||||||
|
contentCurationItemRepository.save(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request.itemIdList.forEach { contentId ->
|
||||||
|
val audioContent = contentRepository.findByIdAndActiveTrue(contentId)
|
||||||
|
|
||||||
|
if (audioContent != null) {
|
||||||
|
val item = contentCurationItemRepository.findByCurationIdAndContentId(
|
||||||
|
curationId = request.curationId,
|
||||||
|
contentId = audioContent.id
|
||||||
|
) ?: AudioContentCurationItem()
|
||||||
|
item.curation = audioContentCuration
|
||||||
|
item.content = audioContent
|
||||||
|
item.isActive = true
|
||||||
|
contentCurationItemRepository.save(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun removeItemInCuration(request: RemoveItemInCurationRequest) {
|
||||||
|
val audioContentCurationItem = contentCurationItemRepository.findByCurationIdAndItemId(
|
||||||
|
curationId = request.curationId,
|
||||||
|
itemId = request.itemId
|
||||||
|
)
|
||||||
|
|
||||||
|
audioContentCurationItem?.isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateItemInCurationOrders(request: UpdateCurationItemOrdersRequest) {
|
||||||
|
val ids = request.itemIds
|
||||||
|
for (index in ids.indices) {
|
||||||
|
val item = contentCurationItemRepository.findByCurationIdAndItemId(
|
||||||
|
curationId = request.curationId,
|
||||||
|
itemId = ids[index]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (item != null) {
|
||||||
|
item.orders = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
data class CreateContentCurationRequest(
|
||||||
|
val tabId: Long,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isSeries: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateContentCurationRequest(
|
||||||
|
val id: Long,
|
||||||
|
val tabId: Long?,
|
||||||
|
val title: String?,
|
||||||
|
val description: String?,
|
||||||
|
val isAdult: Boolean?,
|
||||||
|
val isSeries: Boolean?,
|
||||||
|
val isActive: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateContentCurationOrdersRequest(
|
||||||
|
val ids: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetAdminContentCurationResponse @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val tabId: Long,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isSeries: Boolean
|
||||||
|
)
|
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetCurationItemResponse @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val desc: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
data class RemoveItemInCurationRequest(
|
||||||
|
val curationId: Long,
|
||||||
|
val itemId: Long
|
||||||
|
)
|
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class SearchCurationItemResponse @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String
|
||||||
|
)
|
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation
|
||||||
|
|
||||||
|
data class UpdateCurationItemOrdersRequest(
|
||||||
|
val curationId: Long,
|
||||||
|
val itemIds: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,72 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCurationItem
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCuration.contentHashTagCuration
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCurationItem.contentHashTagCurationItem
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AdminContentHashTagCurationItemRepository :
|
||||||
|
JpaRepository<ContentHashTagCurationItem, Long>,
|
||||||
|
AdminContentHashTagCurationItemQueryRepository
|
||||||
|
|
||||||
|
interface AdminContentHashTagCurationItemQueryRepository {
|
||||||
|
fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse>
|
||||||
|
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem?
|
||||||
|
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem?
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminContentHashTagCurationItemQueryRepositoryImpl(
|
||||||
|
val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) : AdminContentHashTagCurationItemQueryRepository {
|
||||||
|
override fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminHashTagCurationItemResponse(
|
||||||
|
contentHashTagCurationItem.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.detail,
|
||||||
|
audioContent.coverImage.prepend("/").prepend(imageHost),
|
||||||
|
audioContent.member.nickname.coalesce(""),
|
||||||
|
audioContent.isAdult
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(contentHashTagCurationItem)
|
||||||
|
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||||
|
.innerJoin(contentHashTagCurationItem.content, audioContent)
|
||||||
|
.where(
|
||||||
|
contentHashTagCuration.id.eq(curationId),
|
||||||
|
contentHashTagCurationItem.isActive.isTrue,
|
||||||
|
audioContent.isActive.isTrue
|
||||||
|
)
|
||||||
|
.orderBy(contentHashTagCurationItem.orders.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(contentHashTagCurationItem)
|
||||||
|
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||||
|
.innerJoin(contentHashTagCurationItem.content, audioContent)
|
||||||
|
.where(
|
||||||
|
contentHashTagCuration.id.eq(curationId),
|
||||||
|
audioContent.id.eq(contentId)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem? {
|
||||||
|
return queryFactory.selectFrom(contentHashTagCurationItem)
|
||||||
|
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||||
|
.where(
|
||||||
|
contentHashTagCuration.id.eq(curationId),
|
||||||
|
contentHashTagCurationItem.id.eq(itemId)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user