Compare commits
982 Commits
codex/fix-
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
232f40151b | ||
|
|
3b3f99754d | ||
|
|
e14566ee66 | ||
|
|
892312c6d4 | ||
|
|
dfb31771ff | ||
|
|
bf7df629cc | ||
|
|
f17b644a9b | ||
|
|
61f8fa4bb7 | ||
|
|
43929bcdc5 | ||
|
|
6aecb4f583 | ||
|
|
0d2e6a9505 | ||
|
|
b2d70b9bde | ||
|
|
d914579d64 | ||
|
|
8643446d8b | ||
|
|
2db958f8c9 | ||
|
|
fa29d255c9 | ||
|
|
b3fa5e2bef | ||
|
|
a7ef4380d8 | ||
|
|
39d954d98a | ||
|
|
596d1558a2 | ||
|
|
ce04570efb | ||
|
|
215c7077d5 | ||
|
|
a68c925c68 | ||
|
|
4f248e8a71 | ||
|
|
277883f9d9 | ||
|
|
e9e996f291 | ||
|
|
a8667ce5e9 | ||
|
|
0d316af22a | ||
|
|
f8e13af672 | ||
|
|
92d90c997c | ||
|
|
303ec9b6c1 | ||
|
|
90eafe27fd | ||
|
|
98e2ea7ef8 | ||
|
|
e3290f3431 | ||
|
|
160570574c | ||
|
|
cf7b667f30 | ||
|
|
60fa6051b7 | ||
|
|
1c0e90d32d | ||
|
|
a15065575d | ||
|
|
cb958e162e | ||
|
|
660d8ffe51 | ||
|
|
5509a1eead | ||
|
|
1acd776d3b | ||
|
|
53be8d943a | ||
|
|
9957042746 | ||
|
|
302f98f44e | ||
|
|
790c4db8ea | ||
|
|
bbb0a11d49 | ||
|
|
35340319c6 | ||
|
|
343c4d3793 | ||
|
|
87b214cbc0 | ||
|
|
e7f06787d2 | ||
|
|
d7d2fd5dcb | ||
|
|
76b65a1400 | ||
|
|
fa8ee113a2 | ||
|
|
181237adee | ||
|
|
1b8135acfb | ||
|
|
67bbe832a0 | ||
|
|
9d67f7d8d6 | ||
|
|
da0d26c8b5 | ||
|
|
81d64bfc7b | ||
|
|
3e255c1288 | ||
|
|
224e1a1018 | ||
|
|
4456997573 | ||
|
|
ef0f0d013b | ||
|
|
a83ddc40fe | ||
|
|
f36ed28185 | ||
|
|
1d31284dba | ||
|
|
995d68b50b | ||
|
|
55b680ef83 | ||
|
|
024e52b763 | ||
|
|
536979501e | ||
|
|
85a67a6215 | ||
|
|
57a9a98da6 | ||
|
|
e8976a98d4 | ||
|
|
57e6bcaa0c | ||
|
|
c95b2ebdc2 | ||
|
|
83cf7439c9 | ||
|
|
994f4028fc | ||
|
|
2362458024 | ||
|
|
03c92d4861 | ||
|
|
8df566a9c9 | ||
|
|
870d1e2940 | ||
|
|
0033374481 | ||
|
|
8f36422609 | ||
|
|
b98871bed9 | ||
|
|
2cb8c12f65 | ||
|
|
87a256ba0c | ||
|
|
737157e557 | ||
|
|
6f9570dc95 | ||
|
|
12bc405856 | ||
|
|
a2b0cd1a47 | ||
|
|
25a7f1e138 | ||
|
|
a6dd2bfbc2 | ||
|
|
a0ea63700f | ||
|
|
b49e20d010 | ||
|
|
e44443a605 | ||
|
|
0a3bfb9451 | ||
|
|
adfc05b9b2 | ||
|
|
18a6953ff7 | ||
|
|
181ac7bc8f | ||
|
|
9dc9ca9bd8 | ||
|
|
2457efd11d | ||
|
|
b62b9c691f | ||
|
|
180c45bf2d | ||
|
|
263f2deeb1 | ||
|
|
22b813e40b | ||
|
|
d00dbbbd03 | ||
|
|
3b92bdaf2a | ||
|
|
7ce5de7f7c | ||
|
|
28618c7452 | ||
|
|
f8a2ee6ee9 | ||
|
|
ca26b931da | ||
|
|
24fe90cfc6 | ||
|
|
5971700e8a | ||
|
|
f872a32410 | ||
|
|
fffd335ebb | ||
|
|
287d52df10 | ||
|
|
73790d1992 | ||
|
|
3d5cee6e68 | ||
|
|
2f509cc2d8 | ||
|
|
35c503eb6c | ||
|
|
0cf8113691 | ||
|
|
b2a29913aa | ||
|
|
2b6d7c5ab9 | ||
|
|
e9878487e8 | ||
|
|
201af061e4 | ||
|
|
4080f60f60 | ||
|
|
06d76438e8 | ||
|
|
bb955c98ba | ||
|
|
a12368602d | ||
|
|
208c875868 | ||
|
|
39ae8c02cb | ||
|
|
0119605649 | ||
|
|
0d7dc93a67 | ||
|
|
774611f3a8 | ||
|
|
04616a30f3 | ||
|
|
c0ca615439 | ||
|
|
b0597d34b6 | ||
|
|
e3f680ad0f | ||
|
|
c8a1e6d8c8 | ||
|
|
ffebeb46b7 | ||
|
|
2977d2898f | ||
|
|
8869121bcb | ||
|
|
61f6e7c90a | ||
|
|
892aa6a7c6 | ||
|
|
23cc2d1606 | ||
|
|
44addd2a7b | ||
|
|
0bc65077df | ||
|
|
69869348f6 | ||
|
|
4821b77c17 | ||
|
|
4fc7c861ee | ||
|
|
81dfddf6e1 | ||
|
|
8b93aa95cf | ||
|
|
425fc7d2b1 | ||
|
|
0fff73b682 | ||
|
|
e1171212d7 | ||
|
|
e96db5d0d6 | ||
|
|
1083c4241a | ||
|
|
1eeabab41a | ||
|
|
2b5f6f2208 | ||
|
|
bda377336d | ||
|
|
77507f7b18 | ||
|
|
a39f2f7c00 | ||
|
|
229439aa05 | ||
|
|
612881f1b1 | ||
|
|
05c7bc18d7 | ||
|
|
c68c5985f6 | ||
|
|
7d44791011 | ||
|
|
15b992b949 | ||
|
|
4b8229b0a1 | ||
|
|
6e4fbc3c42 | ||
|
|
779264623c | ||
|
|
76aef40de7 | ||
|
|
a1eccb3b1e | ||
|
|
0f75a95dbe | ||
|
|
efbb83924b | ||
|
|
26d1db79f4 | ||
|
|
dc13b2941f | ||
|
|
13c250d392 | ||
|
|
f5b40feaa2 | ||
|
|
c47c318e6f | ||
|
|
c02d993e90 | ||
|
|
f36bcb74ca | ||
|
|
2263fd97db | ||
|
|
9234d1099e | ||
|
|
373dece19d | ||
|
|
b09828bcc2 | ||
|
|
8751a7707c | ||
|
|
f91b240802 | ||
|
|
062b289f7a | ||
|
|
c1dc77f6db | ||
|
|
cea60175c2 | ||
|
|
2bd3630512 | ||
|
|
a9d8181940 | ||
|
|
4cc108094d | ||
|
|
bfa57cce44 | ||
|
|
8ebdcd94f5 | ||
|
|
9991210db2 | ||
|
|
1c59815afa | ||
|
|
e7593c8ebf | ||
|
|
bc767a6ac9 | ||
|
|
1c1915285d | ||
|
|
b6c2471bc3 | ||
|
|
4cc2800f09 | ||
|
|
396434a82e | ||
|
|
07c6b53f82 | ||
|
|
930a861ba6 | ||
|
|
1f4e1dea75 | ||
|
|
bc617837be | ||
|
|
17e4862eaf | ||
|
|
72b2b82e02 | ||
|
|
70f7442f0c | ||
|
|
2b2deb8f66 | ||
|
|
0a7a433bc6 | ||
|
|
b64f9ef1f6 | ||
|
|
f22ca9cdcd | ||
|
|
d26b96ebd1 | ||
|
|
13cc981421 | ||
|
|
efc8589ca0 | ||
|
|
940690889c | ||
|
|
d46420ef81 | ||
|
|
b36b5b59dc | ||
|
|
cf96806f80 | ||
|
|
3d0d0496b6 | ||
|
|
f67e220894 | ||
|
|
9306e35b84 | ||
|
|
d2268a1944 | ||
|
|
6baa4d4233 | ||
|
|
ef9d90455f | ||
|
|
5d499956d7 | ||
|
|
9101ed336c | ||
|
|
28e3ebb911 | ||
|
|
e93e33fe43 | ||
|
|
0ebeccf21e | ||
|
|
89842b82e9 | ||
|
|
58594229f2 | ||
|
|
b4a811ff4e | ||
|
|
7067630bcc | ||
|
|
b28e8d4bc9 | ||
|
|
063866cc3a | ||
|
|
6f968d16aa | ||
|
|
6db969cc4d | ||
|
|
6ea9b4a33c | ||
|
|
bcfc40d795 | ||
|
|
c5c7066b92 | ||
|
|
51b73fcc93 | ||
|
|
da181b9d6d | ||
|
|
134e3fc866 | ||
|
|
c3758cafe8 | ||
|
|
1a21ba8935 | ||
|
|
a397ebe79b | ||
|
|
abbdb224e0 | ||
|
|
f4fb3b2544 | ||
|
|
ae2412a906 | ||
|
|
d8534fb94d | ||
|
|
6497cb92af | ||
|
|
37bef0b2d7 | ||
|
|
3519a41a2e | ||
|
|
ab04a8b6b1 | ||
|
|
ea079e8b8a | ||
|
|
519656359f | ||
|
|
dc64785279 | ||
|
|
9421d004d4 | ||
|
|
90bd41e740 | ||
|
|
7d5c864f64 | ||
|
|
3f35add587 | ||
|
|
37c4306010 | ||
|
|
1e284e15df | ||
|
|
9d76926b8a | ||
|
|
d2ce203236 | ||
|
|
b2228296af | ||
|
|
7020ae19d0 | ||
|
|
227fb6f6cc | ||
|
|
0e46a67ea6 | ||
|
|
b20b705e46 | ||
|
|
4b3ffbab99 | ||
|
|
74039c89f9 | ||
|
|
10dca73d2f | ||
|
|
e37ed1b70b | ||
|
|
8500a7a914 | ||
|
|
3adf722b3b | ||
|
|
791e5a4daf | ||
|
|
7d25e87fbc | ||
|
|
d02c316a70 | ||
|
|
c189c80c05 | ||
|
|
07db73c9c7 | ||
|
|
c296e25927 | ||
|
|
61fc9d799d | ||
|
|
20c6c73f8c | ||
|
|
81d1f79aae | ||
|
|
4ff76d2586 | ||
|
|
f24bc239cc | ||
|
|
143691206d | ||
|
|
15ad85e6f1 | ||
|
|
843e53143d | ||
|
|
16c94690bd | ||
|
|
5be00e7013 | ||
|
|
1e0f62b421 | ||
|
|
a3201f05fb | ||
|
|
62cccb794d | ||
|
|
afa0c7fb8f | ||
|
|
da311806c1 | ||
|
|
1852f87341 | ||
|
|
7010e8a058 | ||
|
|
38ee37d5be | ||
|
|
e398d8e989 | ||
|
|
85e77c265e | ||
|
|
8abdc73497 | ||
|
|
747d9c07d1 | ||
|
|
09cefbedbf | ||
|
|
d772bc182f | ||
|
|
358c53338d | ||
|
|
2110980797 | ||
|
|
1cd89eaa54 | ||
|
|
1d2e7eb96e | ||
|
|
4428e06f1d | ||
|
|
dddff54556 | ||
|
|
e7f7bbac22 | ||
|
|
37aae4ba5c | ||
|
|
54cfc98336 | ||
|
|
d42d38ff7a | ||
|
|
2b4601bd4b | ||
|
|
5071d9c6d5 | ||
|
|
cfaa4cd094 | ||
|
|
fc414794ff | ||
|
|
d8264956c3 | ||
|
|
effa7f25ca | ||
|
|
9b19fae69a | ||
|
|
ec04f64ce1 | ||
|
|
50bea76c0e | ||
|
|
05522fcdc7 | ||
|
|
3820eaa774 | ||
|
|
7effaf920a | ||
|
|
e40a6a3ca9 | ||
|
|
7c9475cfe2 | ||
|
|
17929dd95d | ||
|
|
f478b55538 | ||
|
|
c58c14f9b7 | ||
|
|
990d7cfbf9 | ||
|
|
43fa408f46 | ||
|
|
eb860a74af | ||
|
|
b3d050b42e | ||
|
|
db678a95c6 | ||
|
|
6d66cb48dc | ||
|
|
1fe2994743 | ||
|
|
126b10ce45 | ||
|
|
3b1843b6dd | ||
|
|
6a5d00f086 | ||
|
|
06368a6cf1 | ||
|
|
c38e4bc44c | ||
|
|
e9f25d3b1a | ||
|
|
fe167aa0b9 | ||
|
|
f3421265d2 | ||
|
|
f4817cd6d1 | ||
|
|
5ae0f9311c | ||
|
|
567452f570 | ||
|
|
bb4e866bd0 | ||
|
|
24d0da0864 | ||
|
|
9b53479ab6 | ||
|
|
039d482517 | ||
|
|
7cc32c36b1 | ||
|
|
2288522372 | ||
|
|
a2b72d7c00 | ||
|
|
a6d8add5fa | ||
|
|
ad481cffca | ||
|
|
ce213d4c24 | ||
|
|
68a82fa2ec | ||
|
|
cab8cd06dc | ||
|
|
b77a96938a | ||
|
|
1c28201cb8 | ||
|
|
0e26758585 | ||
|
|
786e60e8e5 | ||
|
|
df4a707e3a | ||
|
|
d94302635a | ||
|
|
9519f66474 | ||
|
|
14ee5faa1f | ||
|
|
92ba475f3b | ||
|
|
2eebc1c004 | ||
|
|
6fffdb0fd6 | ||
|
|
135a6b8c51 | ||
|
|
c43e4b85bc | ||
|
|
fb3a2839db | ||
|
|
db8c896b71 | ||
|
|
2a090442cc | ||
|
|
aa86909598 | ||
|
|
5eb1416c6b | ||
|
|
7320df6d20 | ||
|
|
9406bf3392 | ||
|
|
ccaada8f4e | ||
|
|
5738ce75e8 | ||
|
|
0cf3e8c0f8 | ||
|
|
e2d16845f5 | ||
|
|
cb531d1337 | ||
|
|
b538f99082 | ||
|
|
ba5f0148af | ||
|
|
7dc9903060 | ||
|
|
337e7ca43f | ||
|
|
cc333e4bca | ||
|
|
9e4ad29c7f | ||
|
|
49092780e3 | ||
|
|
6570cfd677 | ||
|
|
1b3bd27655 | ||
|
|
cfe24b5e8e | ||
|
|
52633c8073 | ||
|
|
4802c78156 | ||
|
|
cf2299f9bf | ||
|
|
f03bf92641 | ||
|
|
8bb9c3e3d9 | ||
|
|
8c554465f6 | ||
|
|
05d56df44e | ||
|
|
5b0cbe8ce9 | ||
|
|
140d33d024 | ||
|
|
6ad7e951fe | ||
|
|
da47d37dc5 | ||
|
|
6293f572d8 | ||
|
|
94f4792a32 | ||
|
|
069f4bb8c1 | ||
|
|
7421ec8984 | ||
|
|
90b9d75da2 | ||
|
|
d69b094a7b | ||
|
|
67d80a4edd | ||
|
|
78498c0ac3 | ||
|
|
47c997ad22 | ||
|
|
2cd220e8eb | ||
|
|
8023fa1810 | ||
|
|
04b1b32b9c | ||
|
|
f5d8f37f96 | ||
|
|
4a4c256568 | ||
|
|
3bb14ca6a3 | ||
|
|
080ec97943 | ||
|
|
29232afadc | ||
|
|
4ed679c4f4 | ||
|
|
50848e0da1 | ||
|
|
51819913a0 | ||
|
|
741bd115d5 | ||
|
|
d13ee2257f | ||
|
|
06dea47bec | ||
|
|
dbd322807d | ||
|
|
f89a17f14d | ||
|
|
ac433d6a45 | ||
|
|
5534573a19 | ||
|
|
62e7795e11 | ||
|
|
722d784691 | ||
|
|
35c6d29b8f | ||
|
|
5dab838482 | ||
|
|
67636475aa | ||
|
|
92ae8ae155 | ||
|
|
c0afe9e2a9 | ||
|
|
2c1bef4551 | ||
|
|
202c0f7b59 | ||
|
|
fdd6587fff | ||
|
|
77ea208961 | ||
|
|
96e1259ad7 | ||
|
|
b77b629d9e | ||
|
|
2e2813bcbd | ||
|
|
ad079e6bfd | ||
|
|
47a72dc9b0 | ||
|
|
70a83cbe06 | ||
|
|
0ff6f13c86 | ||
|
|
6f30cf0bc2 | ||
|
|
931aee4c3f | ||
|
|
8895405606 | ||
|
|
12b697d9dd | ||
|
|
49a55bcc36 | ||
|
|
690aae3577 | ||
|
|
93d2c39f6e | ||
|
|
99b824d852 | ||
|
|
67fae4129f | ||
|
|
3739286cca | ||
|
|
ec76e70ad0 | ||
|
|
f482d9ff9d | ||
|
|
5e13b4bdd3 | ||
|
|
78a65c6afe | ||
|
|
84236b0174 | ||
|
|
c337195b16 | ||
|
|
c506aec506 | ||
|
|
aa4274052e | ||
|
|
e96ba3c26f | ||
|
|
36758624c2 | ||
|
|
4427eff78a | ||
|
|
ab85e67d69 | ||
|
|
d7f6bb507d | ||
|
|
bced7807ae | ||
|
|
73bb873bfe | ||
|
|
564ebfbc2c | ||
|
|
9a42b8f32a | ||
|
|
513b1f45a1 | ||
|
|
1b204345a6 | ||
|
|
d146bf2b0d | ||
|
|
864a760b20 | ||
|
|
2ccdc21568 | ||
|
|
ff63d232a9 | ||
|
|
32a624e62d | ||
|
|
5af0c9dee0 | ||
|
|
edaafdd000 | ||
|
|
24838ab714 | ||
|
|
56a80a184b | ||
|
|
ed24ed174b | ||
|
|
3080acb6e4 | ||
|
|
1856eb191b | ||
|
|
0c2a50d620 | ||
|
|
7562de11a5 | ||
|
|
aaacf4efb1 | ||
|
|
1f30cdfe85 | ||
|
|
8b37cf5abb | ||
|
|
4af19a75c9 | ||
|
|
37ea986389 | ||
|
|
fefd0b3b6c | ||
|
|
a31ed29cfa | ||
|
|
2719819ad7 | ||
|
|
27ff9a9c9b | ||
|
|
18fde1052f | ||
|
|
800970f078 | ||
|
|
cbbd1440a1 | ||
|
|
215616d771 | ||
|
|
575e90e558 | ||
|
|
e63d66806d | ||
|
|
1fc0118c5a | ||
|
|
f3512c1184 | ||
|
|
28842c90b1 | ||
|
|
d67cc326c4 | ||
|
|
27c217a630 | ||
|
|
4e3e5f147c | ||
|
|
8767aa31d6 | ||
|
|
a428f472f2 | ||
|
|
8544803e62 | ||
|
|
54874cea7a | ||
|
|
098d82a6a0 | ||
|
|
90eee03198 | ||
|
|
3f152906f2 | ||
|
|
ef71d0b3d4 | ||
|
|
6f80d139ba | ||
|
|
7454931fa5 | ||
|
|
0852664a82 | ||
|
|
5814fb673a | ||
|
|
4ee4266e3d | ||
|
|
6a27fbe1d7 | ||
|
|
38ff04c358 | ||
|
|
fc27200ac1 | ||
|
|
b1998be425 | ||
|
|
72adc5b232 | ||
|
|
d24e67de5d | ||
|
|
eefefac236 | ||
|
|
2f339fdbdb | ||
|
|
3808becc8b | ||
|
|
18db4d7317 | ||
|
|
52cbb71945 | ||
|
|
39c34a9048 | ||
|
|
4baabf2224 | ||
|
|
8023183bc6 | ||
|
|
27efc493b2 | ||
|
|
ca6e45a711 | ||
|
|
803ca9e103 | ||
|
|
9d1e12773a | ||
|
|
5a09934866 | ||
|
|
db1d7981c5 | ||
|
|
6e1a7c773c | ||
|
|
ac4f1064e7 | ||
|
|
4e98fd6a89 | ||
|
|
1bf92ab1ad | ||
|
|
c6ab431c87 | ||
|
|
aaa25d5c2f | ||
|
|
569531b462 | ||
|
|
c3ae97f8ba | ||
|
|
a57f3e6406 | ||
|
|
23582934fa | ||
|
|
5adee4db0e | ||
|
|
a2ccc95b4e | ||
|
|
dc5eb5a637 | ||
|
|
55dd36bd24 | ||
|
|
59232f99ca | ||
|
|
f93f58b055 | ||
|
|
8ad35af199 | ||
|
|
d427a41f6d | ||
|
|
ea53bc3c83 | ||
|
|
3a39cfdb49 | ||
|
|
3d1b8b8e6e | ||
|
|
f0e58d1efe | ||
|
|
5c4aca5ab8 | ||
|
|
fff59e800d | ||
|
|
b42ed19160 | ||
|
|
6fd663d983 | ||
|
|
fd6fc11630 | ||
|
|
d7bfeed259 | ||
|
|
c5e4da5e07 | ||
|
|
b87932560b | ||
|
|
58ff8b177e | ||
|
|
4f6b585735 | ||
|
|
ac81bccd20 | ||
|
|
351447e3d1 | ||
|
|
453d8fa68b | ||
|
|
2c5b38ee9e | ||
|
|
b5fd5a3edc | ||
|
|
ee717aced2 | ||
|
|
9a9152593e | ||
|
|
856d3dd513 | ||
|
|
0e42a3335a | ||
|
|
d96aae59d2 | ||
|
|
122722d0e9 | ||
|
|
0c2264e509 | ||
|
|
1e503e26f2 | ||
|
|
ec0fd63e30 | ||
|
|
dfd4c70b6e | ||
|
|
d79dc8877d | ||
|
|
e979350d40 | ||
|
|
99bf80a47a | ||
|
|
bfadda1e7d | ||
|
|
906998a07f | ||
|
|
02287c05be | ||
|
|
56aed4603e | ||
|
|
a1fa7b2d5b | ||
|
|
083c7980c6 | ||
|
|
3d51f29be7 | ||
|
|
d243e3a9d6 | ||
|
|
2b3c60f9a7 | ||
|
|
8b948a20cd | ||
|
|
5053ac213d | ||
|
|
e5ec801785 | ||
|
|
31e25232d0 | ||
|
|
cdc92aeebe | ||
|
|
d2c2213197 | ||
|
|
c687ffed54 | ||
|
|
5bc9ff45d7 | ||
|
|
78c7681bc8 | ||
|
|
5eb206a358 | ||
|
|
18179cca22 | ||
|
|
2b28cb2ac1 | ||
|
|
610a645092 | ||
|
|
504ca55cad | ||
|
|
0fc1415a14 | ||
|
|
50a84220fe | ||
|
|
af3e049c23 | ||
|
|
c33b411659 | ||
|
|
e8a162d859 | ||
|
|
e819926cf3 | ||
|
|
013d47e8e4 | ||
|
|
6cc76593e4 | ||
|
|
a2a08331e2 | ||
|
|
3eabafadf8 | ||
|
|
62c1983fd5 | ||
|
|
689b719e18 | ||
|
|
c6eccb01b9 | ||
|
|
cdf7e61157 | ||
|
|
d23511ecb9 | ||
|
|
c76708d2ff | ||
|
|
d978bd428e | ||
|
|
e5954cfb62 | ||
|
|
cb614b9739 | ||
|
|
88ce6b682d | ||
|
|
e02db635c4 | ||
|
|
231379181a | ||
|
|
bd9ce67d4b | ||
|
|
6527b3790e | ||
|
|
f01e8c942a | ||
|
|
1e1ae29d32 | ||
|
|
d31a8bfee4 | ||
|
|
29a96595f7 | ||
|
|
2b242367d7 | ||
|
|
3f0cd2bf0f | ||
|
|
a98a631378 | ||
|
|
7701d359dc | ||
|
|
ffd9ef8a32 | ||
|
|
36cd5ab171 | ||
|
|
58d86fa065 | ||
|
|
df71cf901b | ||
|
|
ac3fc6702a | ||
|
|
b0eef220a6 | ||
|
|
02d366e2c7 | ||
|
|
6409531a64 | ||
|
|
175ab79b27 | ||
|
|
b543953d22 | ||
|
|
b4fef68af5 | ||
|
|
6c48a38212 | ||
|
|
8a3e4d8e98 | ||
|
|
cd73747164 | ||
|
|
0ee58df868 | ||
|
|
6fed8131f6 | ||
|
|
d75c08396a | ||
|
|
3a742fbb00 | ||
|
|
9c2b1f6e98 | ||
|
|
28b33d8c44 | ||
|
|
1f99a10322 | ||
|
|
743c3dbc72 | ||
|
|
d46a446f2b | ||
|
|
75a785f612 | ||
|
|
e79b75f340 | ||
|
|
1f6f470ab5 | ||
|
|
583d4042f5 | ||
|
|
8437c1c714 | ||
|
|
2613fe6cf1 | ||
|
|
a15d541b72 | ||
|
|
8657a06f52 | ||
|
|
09900b34aa | ||
|
|
4e1c3f5839 | ||
|
|
d97cc7df5e | ||
|
|
151242f3ba | ||
|
|
b2783a0168 | ||
|
|
c79bcac217 | ||
|
|
9a06da3bc1 | ||
|
|
98bbc36453 | ||
|
|
4a04f4ec17 | ||
|
|
77be2bfebb | ||
|
|
cf4ca89e19 | ||
|
|
094fc78d92 | ||
|
|
da3d2a6a71 | ||
|
|
15cba0c96e | ||
|
|
98a79acad9 | ||
|
|
4947978f81 | ||
|
|
24cc479a56 | ||
|
|
8ee1347b17 | ||
|
|
7e95120341 | ||
|
|
2f261983ac | ||
|
|
e8e7b9a245 | ||
|
|
d2bd949ac8 | ||
|
|
605654ec99 | ||
|
|
88127fcf34 | ||
|
|
0a82f0036b | ||
|
|
3a979277e4 | ||
|
|
1c582fbbf1 | ||
|
|
92452da19a | ||
|
|
a2ccaae7aa | ||
|
|
23371d4433 | ||
|
|
e05d65cf49 | ||
|
|
809a78fee3 | ||
|
|
aaf9b35a45 | ||
|
|
61c0336a78 | ||
|
|
69c913394f | ||
|
|
0ed9ad2f2a | ||
|
|
67e912381b | ||
|
|
a6a1c72a37 | ||
|
|
d77baa8a93 | ||
|
|
fce4832407 | ||
|
|
91c8cc9607 | ||
|
|
02273e018f | ||
|
|
4af10ecf79 | ||
|
|
d34ed3c058 | ||
|
|
8372e06949 | ||
|
|
a74cb0c272 | ||
|
|
5388767a2f | ||
|
|
97dda9601e | ||
|
|
cddbb602bf | ||
|
|
f21ed1f062 | ||
|
|
c009616f74 | ||
|
|
84ab87878a | ||
|
|
c53f91913c | ||
|
|
feed97154a | ||
|
|
f69562516d | ||
|
|
0b8e550097 | ||
|
|
cf722f5707 | ||
|
|
67e54c5106 | ||
|
|
d3dcc98122 | ||
|
|
c648d4cf39 | ||
|
|
41a5eda311 | ||
|
|
c6e0dc6a1d | ||
|
|
92e630df22 | ||
|
|
c6b0f32b09 | ||
|
|
5f5b6f84a8 | ||
|
|
cd57d478f2 | ||
|
|
da07313df8 | ||
|
|
c08ecb5e33 | ||
|
|
0a722c81c5 | ||
|
|
15071471b2 | ||
|
|
98a9939738 | ||
|
|
9554030054 | ||
|
|
72e9a77373 | ||
|
|
ed7dcd9414 | ||
|
|
79fe8b5997 | ||
|
|
cfce4d7d1d | ||
|
|
b7f5d8485c | ||
|
|
d4677a5799 | ||
|
|
99644046fc | ||
|
|
22c9bd7d39 | ||
|
|
3fc6929075 | ||
|
|
4eed6889d6 | ||
|
|
959b0f6a48 | ||
|
|
91ffacc335 | ||
|
|
4969a759aa | ||
|
|
81e3a80d35 | ||
|
|
d717ce03c1 | ||
|
|
66035447a8 | ||
|
|
fa1148bc4e | ||
|
|
f60f184c84 | ||
|
|
06ffb180fe | ||
|
|
1b892828f1 | ||
|
|
1aa88ab0fe | ||
|
|
86126699d3 | ||
|
|
a6a07b9bda | ||
|
|
d8b3c68150 | ||
|
|
318b481c4b | ||
|
|
7338b891db | ||
|
|
eb18dc8e94 | ||
|
|
aec5321f89 | ||
|
|
2e658f37a4 | ||
|
|
7ccb2a44e3 | ||
|
|
0fa08e2260 | ||
|
|
38a49f7414 | ||
|
|
fb89c9fb25 | ||
|
|
e9458f5419 | ||
|
|
2d87c8f23d | ||
|
|
cb281e4030 | ||
|
|
9b85d77158 | ||
|
|
a3b28eafe4 | ||
|
|
805a8df7d3 | ||
|
|
02be045f55 | ||
|
|
ac3c7b7bec | ||
|
|
c344b5b4ae | ||
|
|
e7a1e1d159 | ||
|
|
30b56e54cf | ||
|
|
cc525c1c27 | ||
|
|
3f2829cd37 | ||
|
|
3258a42b44 | ||
|
|
a64fd71bbe | ||
|
|
1a12bec7b1 | ||
|
|
fbca19791a | ||
|
|
10b6fdd1cb | ||
|
|
7dd1f1b3d0 | ||
|
|
df92ff664c | ||
|
|
73168c1859 | ||
|
|
77856ff9af | ||
|
|
df49b21620 | ||
|
|
fbe2c66955 | ||
|
|
df7ca77652 | ||
|
|
fe84e3f2fa | ||
|
|
c307732696 | ||
|
|
35bcd2cdc2 | ||
|
|
a29bf7d860 | ||
|
|
27393c15f2 | ||
|
|
c91a787f29 | ||
|
|
6096712291 | ||
|
|
6d20addcde | ||
|
|
d8f9fd670c | ||
|
|
5ebe739917 | ||
|
|
022edc866a | ||
|
|
b06815cc59 | ||
|
|
f1b223a3c9 | ||
|
|
e65273daa6 | ||
|
|
d3a2acb605 | ||
|
|
bced24e47d | ||
|
|
425ad03e6f | ||
|
|
4462d8f711 | ||
|
|
1b31977ec6 | ||
|
|
42693cb1ff | ||
|
|
6b500466fc | ||
|
|
c84262eb88 | ||
|
|
fa2ffaa64a | ||
|
|
3037c856d0 | ||
|
|
7b1ce3f070 | ||
|
|
f4a15b3448 | ||
|
|
239f1f8c84 | ||
|
|
ac303184c4 | ||
|
|
7f16bbdb94 | ||
|
|
f1c83b0f68 | ||
|
|
22c2b1564d | ||
|
|
628d28c12d | ||
|
|
2577992ee3 | ||
|
|
5b837c9d7f | ||
|
|
017ad5bf54 | ||
|
|
f076b70e9b | ||
|
|
62d12ad2a7 | ||
|
|
923854bbc6 | ||
|
|
9ca5d7b167 | ||
|
|
9c3e1d17f0 | ||
|
|
7906062945 | ||
|
|
785c36d339 | ||
|
|
197cbca99c | ||
|
|
b1076d7256 | ||
|
|
ce94cd7e73 | ||
|
|
90147d6cd9 | ||
|
|
2c187cf2cd | ||
|
|
0b6d4f9709 | ||
|
|
cf3b6d8fc7 | ||
|
|
8d98c876d2 | ||
|
|
df4df1933a | ||
|
|
7507f1bb03 | ||
|
|
9b4c36c76a | ||
|
|
edfc81aeb0 | ||
|
|
7bd1225b27 | ||
|
|
2dd56e27af | ||
|
|
c3ecef3609 | ||
|
|
efc74d0f77 | ||
|
|
f27cb5c703 | ||
|
|
a756c2fab3 | ||
|
|
4e2171a8a6 | ||
|
|
bcbdff8768 | ||
|
|
b976a1f46f | ||
|
|
b9fd9711de | ||
|
|
642a527dcf | ||
|
|
88afcc5a8e | ||
|
|
2c5462cd97 | ||
|
|
2f29946b11 | ||
|
|
e27aa34cfd | ||
|
|
2322b2da15 | ||
|
|
79261054f9 | ||
|
|
86633e1f21 | ||
|
|
784598a6f0 | ||
|
|
fdad0e5d34 | ||
|
|
ebf63c4072 | ||
|
|
354d6bdaf9 | ||
|
|
d9aebdebdc | ||
|
|
d6f6495b35 | ||
|
|
300f8705ef | ||
|
|
1f74a29dce | ||
|
|
27ef792b11 | ||
|
|
8dd2d59617 | ||
|
|
077ba448d7 | ||
|
|
9ce85f2769 | ||
|
|
f5557cbf08 | ||
|
|
e042c499e1 | ||
|
|
e01afb168c | ||
|
|
c1d81eb1d1 | ||
|
|
2b0b429866 | ||
|
|
8ea85d78ee | ||
|
|
3b506fe8a8 | ||
|
|
3cc7a4c01a | ||
|
|
2e749a5672 | ||
|
|
7d553d7750 | ||
|
|
16105cef54 | ||
|
|
2b824d94f2 | ||
|
|
00d3c563e2 | ||
|
|
b26891261c | ||
|
|
c1d19b854b | ||
|
|
72e7ccf262 | ||
|
|
84ca6fd28c | ||
|
|
d1c148c5c4 | ||
|
|
ef58630dae | ||
|
|
f025e82e7c | ||
|
|
4380a988f7 | ||
|
|
2899f7af48 | ||
|
|
d4b05256a3 | ||
|
|
57a26e375d | ||
|
|
8a202c4fba | ||
|
|
089b2a3f5f | ||
|
|
0b3d7a21d5 | ||
|
|
fe8a705a28 | ||
|
|
974c7ba83e | ||
|
|
f2937d735d | ||
|
|
423248c574 | ||
|
|
5126cfda8c | ||
|
|
e009875797 | ||
|
|
04ff17f796 | ||
|
|
e9c9fbd742 | ||
|
|
b385945c2d | ||
|
|
24cbed2eda | ||
|
|
ba073b71a6 | ||
|
|
5ff098ea21 | ||
|
|
f6713b956e | ||
|
|
b8ea12646f | ||
|
|
e573e54c2b | ||
|
|
8ec005d392 | ||
|
|
b1f92f61a6 | ||
|
|
824b4dd8aa | ||
|
|
6b08db7e58 | ||
|
|
6f3830b3f7 | ||
|
|
d70dad723f | ||
|
|
2cf89e4802 | ||
|
|
1fc6460ae0 | ||
|
|
a04e5c2f6f | ||
|
|
77b26937f5 | ||
|
|
a1134b9d4b | ||
|
|
600f6ac1d1 | ||
|
|
9ad50b35c9 | ||
|
|
867ee3907b | ||
|
|
58fcd42745 | ||
|
|
0ee62a3a04 | ||
|
|
f0bc7a22a0 | ||
|
|
f6c0c8e226 | ||
|
|
8f3c0d6710 | ||
|
|
4f738778db | ||
|
|
84b45f785d | ||
|
|
df56d7e885 | ||
|
|
76176e135c | ||
|
|
ab87e0e51c | ||
|
|
5346a063bf | ||
|
|
e53f2130b8 | ||
|
|
1e87e9252d | ||
|
|
3fc4d29dce | ||
|
|
bcdac9d9b2 | ||
|
|
ea9710d16f | ||
|
|
1a1b20b9cf | ||
|
|
b63ebb8fae | ||
|
|
e0f7299a86 |
118
.env.example
Normal file
@@ -0,0 +1,118 @@
|
||||
# === Core Service Ports ===
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
OPENSEARCH_DASHBOARDS_PORT=5601
|
||||
OPENSEARCH_ENABLED=true
|
||||
OPENSEARCH_SCHEME=http
|
||||
OPENSEARCH_USERNAME=
|
||||
OPENSEARCH_PASSWORD=
|
||||
OPENSEARCH_HOST=opensearch
|
||||
|
||||
# === Database Configuration ===
|
||||
MYSQL_DATABASE=openisle
|
||||
MYSQL_ROOT_PASSWORD=openisle
|
||||
MYSQL_USER=openisle
|
||||
MYSQL_PASSWORD=openisle
|
||||
MYSQL_HOST=mysql
|
||||
|
||||
# === Redis Configuration ===
|
||||
REDIS_HOST=redis
|
||||
REDIS_DATABASE=0
|
||||
|
||||
# === RabbitMQ Configuration ===
|
||||
RABBITMQ_HOST=rabbitmq
|
||||
RABBITMQ_USERNAME=nagisa
|
||||
RABBITMQ_PASSWORD=nagisa
|
||||
|
||||
# === Backend Application Secrets ===
|
||||
JWT_SECRET=change-me-jwt-secret
|
||||
JWT_REASON_SECRET=change-me-jwt-reason-secret
|
||||
JWT_RESET_SECRET=change-me-jwt-reset-secret
|
||||
JWT_INVITE_SECRET=change-me-jwt-invite-secret
|
||||
JWT_EXPIRATION=2592000000
|
||||
PASSWORD_STRENGTH=LOW
|
||||
POST_PUBLISH_MODE=DIRECT
|
||||
REGISTER_MODE=WHITELIST
|
||||
UPLOAD_CHECK_TYPE=true
|
||||
UPLOAD_MAX_SIZE=5242880
|
||||
AVATAR_STYLE=pixel-art-neutral
|
||||
AVATAR_SIZE=128
|
||||
AVATAR_BASE_URL=https://api.dicebear.com/6.x
|
||||
USER_POSTS_LIMIT=10
|
||||
USER_REPLIES_LIMIT=50
|
||||
SNIPPET_LENGTH=200
|
||||
SEARCH_INDEX_PREFIX=openisle
|
||||
SEARCH_HIGHLIGHT_FRAGMENT_SIZE=200
|
||||
SEARCH_REINDEX_ON_STARTUP=true
|
||||
SEARCH_REINDEX_BATCH_SIZE=500
|
||||
CAPTCHA_ENABLED=false
|
||||
RECAPTCHA_SECRET_KEY=
|
||||
CAPTCHA_REGISTER_ENABLED=false
|
||||
CAPTCHA_LOGIN_ENABLED=false
|
||||
CAPTCHA_POST_ENABLED=false
|
||||
CAPTCHA_COMMENT_ENABLED=false
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=
|
||||
COS_BASE_URL=https://<你的cos>.cos.accelerate.myqcloud.com
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_BUCKET_NAME=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
TWITTER_CLIENT_SECRET=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
AI_FORMAT_LIMIT=3
|
||||
WEBSITE_URL=http://localhost:3000
|
||||
WEBPUSH_PUBLIC_KEY=
|
||||
WEBPUSH_PRIVATE_KEY=
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# === Frontend (Nuxt) ===
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
# 线上环境
|
||||
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
# 测试环境
|
||||
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
|
||||
# 线上环境
|
||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
|
||||
# 测试环境
|
||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket
|
||||
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
|
||||
# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
# 线上
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
# 测试环境 (www.staging.open-isle.com)
|
||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM
|
||||
# 本地
|
||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
||||
|
||||
# 线上 & 本地均可使用
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
|
||||
# 线上 & 本地均可使用
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
# 线上
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 测试环境 (www.staging.open-isle.com)
|
||||
# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011
|
||||
|
||||
19
.github/ISSUE_TEMPLATE/新功能建议.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: 新功能建议
|
||||
about: 请为该项目提出一个想法
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**你的功能请求是否与某个问题相关?请描述。**
|
||||
请清晰、简洁地说明问题。例如:“我经常因为……而感到困扰。”
|
||||
|
||||
**你期望的解决方案**
|
||||
请清晰、简洁地描述你希望发生的事情/功能如何工作。
|
||||
|
||||
**你考虑过的替代方案**
|
||||
请清晰、简洁地说明你已考虑过的其他解决方案或功能。
|
||||
|
||||
**其他上下文**
|
||||
在此添加与功能请求相关的其他信息或截图。
|
||||
40
.github/ISSUE_TEMPLATE/错误-bug报告.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: 错误/Bug报告
|
||||
about: 创建报告以帮助我们改进
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**描述 Bug**
|
||||
对该 Bug 进行清晰简明的描述。
|
||||
|
||||
**复现步骤**
|
||||
复现该问题的步骤:
|
||||
|
||||
1. 进入 '...'
|
||||
2. 点击 '...'
|
||||
3. 下拉到 '...'
|
||||
4. 看到错误
|
||||
|
||||
**预期行为**
|
||||
清晰简明地描述你期望发生的情况。
|
||||
|
||||
**截图**
|
||||
如果适用,请添加截图以帮助解释问题。
|
||||
|
||||
**桌面端(请完成以下信息):**
|
||||
|
||||
- 操作系统:\[例如 iOS]
|
||||
- 浏览器:\[例如 Chrome、Safari]
|
||||
- 版本:\[例如 22]
|
||||
|
||||
**移动端(请完成以下信息):**
|
||||
|
||||
- 设备:\[例如 iPhone6]
|
||||
- 操作系统:\[例如 iOS8.1]
|
||||
- 浏览器:\[例如 系统自带浏览器、Safari]
|
||||
- 版本:\[例如 22]
|
||||
|
||||
**附加上下文**
|
||||
在此添加与问题相关的其他上下文信息。
|
||||
52
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-id:
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# 文档发布自己的排队锁,不影响服务器部署
|
||||
concurrency:
|
||||
group: openisle-docs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Log build
|
||||
run: echo "Running documentation deployment from build ${{ inputs.build-id }}"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Bun dependencies
|
||||
run: bun install
|
||||
working-directory: ./docs
|
||||
|
||||
- name: Generate API MDX
|
||||
run: bun run generate
|
||||
working-directory: ./docs
|
||||
|
||||
- name: Build documentation
|
||||
run: bun run build
|
||||
working-directory: ./docs
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: ./docs/out
|
||||
39
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Staging CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥)
|
||||
concurrency:
|
||||
group: openisle-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Deploy
|
||||
if: ${{ !github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server (staging)
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh
|
||||
|
||||
deploy-docs:
|
||||
needs: build-and-deploy
|
||||
if: ${{ success() }}
|
||||
uses: ./.github/workflows/deploy-docs.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
build-id: ${{ github.run_id }}
|
||||
29
.github/workflows/deploy.yml
vendored
@@ -1,9 +1,14 @@
|
||||
name: CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00)
|
||||
|
||||
# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑
|
||||
concurrency:
|
||||
group: openisle-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -13,26 +18,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# - uses: actions/setup-java@v4
|
||||
# with:
|
||||
# java-version: '17'
|
||||
# distribution: 'temurin'
|
||||
|
||||
# - run: mvn -B clean package -DskipTests
|
||||
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
|
||||
# - run: |
|
||||
# cd open-isle-cli
|
||||
# npm ci
|
||||
# npm run build
|
||||
|
||||
- name: Deploy to Server
|
||||
- name: Deploy to Server (prod)
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy.sh
|
||||
script: bash /opt/openisle/OpenIsle/deploy/deploy.sh
|
||||
|
||||
31
.gitignore
vendored
@@ -1,7 +1,32 @@
|
||||
# IDE
|
||||
.idea
|
||||
target
|
||||
openisle.iml
|
||||
|
||||
# log
|
||||
logs
|
||||
|
||||
# deps
|
||||
node_modules
|
||||
|
||||
# test & build
|
||||
coverage
|
||||
out/
|
||||
build
|
||||
dist
|
||||
open-isle.env
|
||||
logs
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
# env
|
||||
*.env
|
||||
.env*.local
|
||||
|
||||
# others
|
||||
openisle.iml
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
32
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# OpenIsle Code of Conduct
|
||||
|
||||
Like the technical community as a whole, the OpenIsle team and community is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the mission - including mentorship, teaching, and connecting people.
|
||||
|
||||
Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, mentors and those seeking help and guidance.
|
||||
|
||||
This isn’t an exhaustive list of things that you can’t do. Rather, take it in the spirit in which it’s intended - a guide to make it easier to enrich all of us and the technical communities in which we participate.
|
||||
|
||||
This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
|
||||
|
||||
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
|
||||
|
||||
- **Be friendly and patient.**
|
||||
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
|
||||
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
|
||||
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community.
|
||||
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
|
||||
- Violent threats or language directed against another person.
|
||||
- Discriminatory jokes and language.
|
||||
- Posting sexually explicit or violent material.
|
||||
- Posting (or threatening to post) other people's personally identifying information ("doxing").
|
||||
- Personal insults, especially those using racist or sexist terms.
|
||||
- Unwelcome sexual attention.
|
||||
- Advocating for, or encouraging, any of the above behavior.
|
||||
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
||||
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
||||
|
||||
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, please see . If that doesn't answer your questions, feel free to [contact us](mailto:).
|
||||
223
CONTRIBUTING.md
Normal file
@@ -0,0 +1,223 @@
|
||||
- [前置工作](#前置工作)
|
||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||
- [启动后端服务](#启动后端服务)
|
||||
- [本地 IDEA](#本地-idea)
|
||||
- [配置环境变量](#配置环境变量)
|
||||
- [配置 IDEA 参数](#配置-idea-参数)
|
||||
- [启动前端服务](#启动前端服务)
|
||||
- [连接预发或正式环境](#连接预发或正式环境)
|
||||
- [其他配置](#其他配置)
|
||||
- [配置第三方登录以GitHub为例](#配置第三方登录以github为例)
|
||||
- [配置Resend邮箱服务](#配置resend邮箱服务)
|
||||
- [API文档](#api文档)
|
||||
- [OpenAPI文档](#openapi文档)
|
||||
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
|
||||
- [OpenAPI文档使用](#openapi文档使用)
|
||||
- [OpenAPI文档应用场景](#openapi文档应用场景)
|
||||
|
||||
## 前置工作
|
||||
|
||||
先克隆仓库:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/nagisa77/OpenIsle.git
|
||||
cd OpenIsle
|
||||
```
|
||||
|
||||
- 后端开发环境
|
||||
- JDK 17+
|
||||
- 前端开发环境
|
||||
- Node.JS 20+
|
||||
|
||||
## 前端极速调试(Docker 全量环境)
|
||||
|
||||
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server,适合需要全链路联调的场景。
|
||||
|
||||
1. 准备环境变量文件:
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||
2. 启动 Dev Profile:
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev build
|
||||
```
|
||||
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev up -d
|
||||
```
|
||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||
修改前端代码,页面会热更新。
|
||||
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。
|
||||
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev up -d --force-recreate
|
||||
```
|
||||
|
||||
3. 查看服务状态:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
|
||||
```
|
||||
4. 停止所有容器:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||
```
|
||||
|
||||
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
|
||||
```
|
||||
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
|
||||
|
||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||
|
||||
## 启动后端服务
|
||||
|
||||
启动后端服务有多种方式,选择一种即可。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 仅想修改前端的朋友可不用部署后端服务。转到 [启动前端服务](#启动前端服务) 章节。
|
||||
|
||||
### 本地 IDEA
|
||||
|
||||
```shell
|
||||
cd backend/
|
||||
```
|
||||
|
||||
IDEA 打开 `backend/` 文件夹。
|
||||
|
||||
#### 配置环境变量
|
||||
|
||||
1. 生成环境变量文件:
|
||||
```shell
|
||||
cp open-isle.env.example open-isle.env
|
||||
```
|
||||
`open-isle.env` 才是实际被读取的文件。可在其中补充数据库、第三方服务等配置,`open-isle.env` 已被 Git 忽略,放心修改。
|
||||
2. 在 IDEA 中配置「Environment file」:将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `open-isle.env`,即可让 IDE 读取该文件。
|
||||
3. 需要调整端口或功能开关时,优先修改 `open-isle.env`,例如:
|
||||
```ini
|
||||
SERVER_PORT=8081
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
#### 配置 IDEA 参数
|
||||
|
||||
- 设置 JDK 版本为 Java 17。
|
||||
- 设置 VM Option,最好运行在其他端口(例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。
|
||||
```shell
|
||||
-Dserver.port=8081
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。
|
||||
|
||||

|
||||
|
||||
## 前端连接预发或正式环境
|
||||
|
||||
前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境:
|
||||
|
||||
1. 按需覆盖关键变量:
|
||||
|
||||
```ini
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
|
||||
```
|
||||
将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等)可根据需求调整。
|
||||
|
||||
|
||||
## 其他配置
|
||||
|
||||
### 配置第三方登录以GitHub为例
|
||||
|
||||
- 修改 `application.properties` 配置
|
||||
|
||||

|
||||
|
||||
- 修改 `.env` 配置
|
||||
|
||||

|
||||
|
||||
- 配置第三方登录回调地址
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 配置Resend邮箱服务
|
||||
|
||||
https://resend.com/emails 创建账号并登录
|
||||
|
||||
- `Domains` -> `Add Domain`
|
||||

|
||||
|
||||
- 填写域名
|
||||

|
||||
|
||||
- 等待一段时间后解析成功,创建 key
|
||||
`API Keys` -> `Create API Key`,输入名称,设置 `Permission` 为 `Sending access`
|
||||
**Key 只能查看一次,务必保存下来**
|
||||

|
||||

|
||||

|
||||
- 修改 `.env` 配置中的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL`
|
||||
`RESEND_FROM_EMAIL`: **noreply@域名**
|
||||
`RESEND_API_KEY`:**刚刚复制的 Key**
|
||||

|
||||
|
||||
## API文档
|
||||
|
||||
### OpenAPI文档
|
||||
https://docs.open-isle.com
|
||||
|
||||
### 部署时间线以及文档时效性
|
||||
|
||||
我已经将API Docs的部署融合进本站CI & CD中,目前如下
|
||||
|
||||
- 每次合入main之后,都会构建预发环境 http://staging.open-isle.com/ ,现在文档是紧随其后进行部署,也就是说代码合入main之后,如果是新增后台接口,就可以立即通过OpenAPI文档页面进行查看和调试,但是如果想通过OpenAPI调试需要选择预发环境的
|
||||
- 每日凌晨三点会构建并重新部署正式环境,届时当日合入main的新后台API也可以通过OpenAPI文档页面调试
|
||||
|
||||

|
||||
|
||||
👆如图是合入main之后构建预发+docs的情形,总大约耗时4分钟左右
|
||||
|
||||
### OpenAPI文档使用
|
||||
|
||||
- 预发环境/正式环境切换,以通过如下位置切换API环境
|
||||
|
||||

|
||||
|
||||
- API分两种,一种是需要鉴权(需登录后的token),另一种是直接访问,可以直接访问的GET请求,直接点击Send即可调试,如下👇,比如本站的推荐流rss: /api/rss: https://docs.open-isle.com/openapi/feed
|
||||
|
||||

|
||||
|
||||
- 需要登陆的API,比如关注,取消关注,发帖等,则需要提供token,目前在“API与调试”可获取自身token,可点击link看看👉 https://www.open-isle.com/about?tab=api
|
||||
|
||||

|
||||
|
||||
copy完token之后,粘贴到Bear之后, 即可发送调试, 如下👇,大家亦可自行尝试:https://docs.open-isle.com/openapi/me
|
||||
|
||||

|
||||
|
||||
#### OpenAPI文档应用场景
|
||||
|
||||
- 方便大部分前端调试的需求,如果有只想做前端/客户端的同学参与本项目,该平台会大大提高效率
|
||||
- 自动化:有自动化发帖/自动化操作的需求,亦可通过该平台实现或调试
|
||||
- API文档: https://docs.open-isle.com/openapi
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Tim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
24
README.md
@@ -1,28 +1,20 @@
|
||||
<p align="center">
|
||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
||||
<br><br>
|
||||
高效的开源社区前后端端平台
|
||||
<br><br>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a>
|
||||
<br>
|
||||
高效的开源社区前后端平台
|
||||
<br><br><br>
|
||||
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||
<br><br><br>
|
||||
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</p>
|
||||
|
||||
## 💡 简介
|
||||
|
||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||
|
||||
## 🚀 部署
|
||||
## 🚧 开发 & 部署
|
||||
|
||||
### 后端
|
||||
|
||||
1. 确保安装 JDK 17 及 Maven
|
||||
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
|
||||
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
|
||||
|
||||
### 前端
|
||||
|
||||
1. `cd open-isle-cli`
|
||||
2. 执行 `npm install`
|
||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
||||
详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
|
||||
|
||||
## ✨ 项目特点
|
||||
|
||||
|
||||
BIN
assets/contributing/backend_img.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
assets/contributing/backend_img_2.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
assets/contributing/backend_img_3.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/contributing/backend_img_4.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
assets/contributing/backend_img_5.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
assets/contributing/backend_img_6.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
assets/contributing/backend_img_7.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/contributing/fontend_img.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/contributing/github_img.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
assets/contributing/github_img_2.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
assets/contributing/image-20250906150459400.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
assets/contributing/image-20250906150541817.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
assets/contributing/image-20250906150811572.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
assets/contributing/image-20250906150924975.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
assets/contributing/image-20250906150944130.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/contributing/image-20250906151218330.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/contributing/resources_img.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
23
backend/.prettierrc
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"proseWrap": "preserve",
|
||||
"plugins": ["prettier-plugin-java"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.java",
|
||||
"options": {
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
|
||||
# 此文件保留作参考用途,如需在 Docker 之外手动配置,可按需复制。
|
||||
|
||||
# === Spring Boot ===
|
||||
SERVER_PORT=8080
|
||||
|
||||
# === Database ===
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<数据库端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
|
||||
# === JWT ===
|
||||
JWT_SECRET=<jwt secret>
|
||||
JWT_REASON_SECRET=<jwt reason secret>
|
||||
JWT_RESET_SECRET=<jwt reset secret>
|
||||
JWT_INVITE_SECRET=<jwt invite secret>
|
||||
JWT_EXPIRATION=2592000000
|
||||
|
||||
# === Redis ===
|
||||
REDIS_HOST=<Redis 地址>
|
||||
REDIS_PORT=<Redis 端口>
|
||||
|
||||
# === Resend ===
|
||||
RESEND_API_KEY=<你的resend-api-key>
|
||||
RESEND_FROM_EMAIL=<你的 resend 发送邮箱>
|
||||
|
||||
# === COS ===
|
||||
# COS_BASE_URL=https://<你的cos>.cos.ap-guangzhou.myqcloud.com
|
||||
@@ -22,6 +39,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
|
||||
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
||||
DISCORD_CLIENT_ID=<你的discord-client-id>
|
||||
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
||||
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
|
||||
|
||||
# === OPENAI ===
|
||||
OPENAI_API_KEY=<你的openai-api-key>
|
||||
@@ -30,4 +48,10 @@ OPENAI_API_KEY=<你的openai-api-key>
|
||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||
|
||||
# LOG_LEVEL=DEBUG
|
||||
# === RabbitMQ ===
|
||||
RABBITMQ_HOST=<你的rabbitmq_host>
|
||||
RABBITMQ_PORT=<你的rabbitmq_port>
|
||||
RABBITMQ_USERNAME=<你的rabbitmq_username>
|
||||
RABBITMQ_PASSWORD=<你的rabbitmq_password>
|
||||
|
||||
# LOG_LEVEL=DEBUG
|
||||
|
||||
@@ -26,6 +26,23 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-hibernate6</artifactId>
|
||||
<version>2.20.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
@@ -38,6 +55,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.vladsch.flexmark</groupId>
|
||||
<artifactId>flexmark-all</artifactId>
|
||||
<version>0.64.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
@@ -100,6 +127,28 @@
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
<version>1.70</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<!-- 高阶 Java 客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-java</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 低阶 RestClient,提供 org.opensearch.client.RestClient 给你的 RestClientTransport 用 -->
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-rest-client</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -127,6 +176,26 @@
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<!-- https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-maven-plugin/README.md -->
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-maven-plugin</artifactId>
|
||||
<version>1.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>generate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<!-- 此处为硬编码,应优化为 env 的配置 -->
|
||||
<apiDocsUrl>http://localhost:8080/api/v3/api-docs</apiDocsUrl>
|
||||
<outputFileName>openapi.json</outputFileName>
|
||||
<outputDir>${project.build.directory}</outputDir>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -7,7 +7,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class OpenIsleApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OpenIsleApplication.class, args);
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OpenIsleApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.openisle.config;
|
||||
import com.openisle.model.Activity;
|
||||
import com.openisle.model.ActivityType;
|
||||
import com.openisle.repository.ActivityRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -10,17 +12,31 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityInitializer implements CommandLineRunner {
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
|
||||
Activity a = new Activity();
|
||||
a.setTitle("🎡建站送奶茶活动");
|
||||
a.setType(ActivityType.MILK_TEA);
|
||||
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
|
||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||
activityRepository.save(a);
|
||||
}
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
|
||||
Activity a = new Activity();
|
||||
a.setTitle("🎡建站送奶茶活动");
|
||||
a.setType(ActivityType.MILK_TEA);
|
||||
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
|
||||
a.setContent(
|
||||
"为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯"
|
||||
);
|
||||
activityRepository.save(a);
|
||||
}
|
||||
|
||||
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
|
||||
Activity a = new Activity();
|
||||
a.setTitle("🎁邀请码送积分活动");
|
||||
a.setType(ActivityType.INVITE_POINTS);
|
||||
a.setIcon("https://img.icons8.com/color/96/gift.png");
|
||||
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
|
||||
a.setStartTime(LocalDateTime.now());
|
||||
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
|
||||
activityRepository.save(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
@Bean(name = "notificationExecutor")
|
||||
public Executor notificationExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("notification-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean(name = "notificationExecutor")
|
||||
public Executor notificationExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("notification-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
|
||||
139
backend/src/main/java/com/openisle/config/CachingConfig.java
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 缓存配置类
|
||||
* @author smallclover
|
||||
* @since 2025-09-04
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CachingConfig {
|
||||
|
||||
// 标签缓存名
|
||||
public static final String TAG_CACHE_NAME = "openisle_tags";
|
||||
// 分类缓存名
|
||||
public static final String CATEGORY_CACHE_NAME = "openisle_categories";
|
||||
// 在线人数缓存名
|
||||
public static final String ONLINE_CACHE_NAME = "openisle_online";
|
||||
// 注册验证码
|
||||
public static final String VERIFY_CACHE_NAME = "openisle_verify";
|
||||
// 发帖频率限制
|
||||
public static final String LIMIT_CACHE_NAME = "openisle_limit";
|
||||
// 用户访问统计
|
||||
public static final String VISIT_CACHE_NAME = "openisle_visit";
|
||||
// 文章缓存
|
||||
public static final String POST_CACHE_NAME = "openisle_posts";
|
||||
|
||||
/**
|
||||
* 自定义Redis的序列化器
|
||||
* @return
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RedisSerializer<Object> redisSerializer() {
|
||||
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310
|
||||
|
||||
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
|
||||
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
|
||||
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
|
||||
// 设置可见性,允许序列化所有元素
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
// Hibernate6Module 可以自动处理懒加载代理对象。
|
||||
// Tag对象的creator是FetchType.LAZY
|
||||
objectMapper.registerModule(
|
||||
new Hibernate6Module()
|
||||
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)
|
||||
// 将 Hibernate 特有的集合类型转换为标准 Java 集合类型
|
||||
// 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息
|
||||
.configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true)
|
||||
);
|
||||
// service的时候带上类型信息
|
||||
// 启用类型信息,避免 LinkedHashMap 问题
|
||||
objectMapper.activateDefaultTyping(
|
||||
LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
return new GenericJackson2JsonRedisSerializer(objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 Spring Cache 使用 RedisCacheManager
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager(
|
||||
RedisConnectionFactory connectionFactory,
|
||||
RedisSerializer<Object> redisSerializer
|
||||
) {
|
||||
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ZERO) // 默认缓存不过期
|
||||
.serializeKeysWith(
|
||||
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
|
||||
)
|
||||
.serializeValuesWith(
|
||||
RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)
|
||||
)
|
||||
.disableCachingNullValues(); // 禁止缓存 null 值
|
||||
|
||||
// 个别缓存单独设置 TTL 时间
|
||||
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
||||
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
|
||||
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
|
||||
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
|
||||
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
|
||||
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
|
||||
|
||||
return RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(config)
|
||||
.withInitialCacheConfigurations(cacheConfigs)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 RedisTemplate,支持直接操作 Redis
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(
|
||||
RedisConnectionFactory connectionFactory,
|
||||
RedisSerializer<Object> redisSerializer
|
||||
) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// key 和 hashKey 使用 String 序列化
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// value 和 hashValue 使用 JSON 序列化
|
||||
template.setValueSerializer(redisSerializer);
|
||||
template.setHashValueSerializer(redisSerializer);
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.repository.MessageConversationRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelInitializer implements CommandLineRunner {
|
||||
|
||||
private final MessageConversationRepository conversationRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (conversationRepository.countByChannelTrue() == 0) {
|
||||
MessageConversation chat = new MessageConversation();
|
||||
chat.setChannel(true);
|
||||
chat.setName("吹水群");
|
||||
chat.setDescription("吹水聊天");
|
||||
chat.setAvatar(
|
||||
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg"
|
||||
);
|
||||
conversationRepository.save(chat);
|
||||
|
||||
MessageConversation tech = new MessageConversation();
|
||||
tech.setChannel(true);
|
||||
tech.setName("技术讨论群");
|
||||
tech.setDescription("讨论技术相关话题");
|
||||
tech.setAvatar(
|
||||
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png"
|
||||
);
|
||||
conversationRepository.save(tech);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,25 @@ package com.openisle.config;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Returns 401 Unauthorized when an authenticated user lacks required privileges.
|
||||
*/
|
||||
@Component
|
||||
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
|
||||
@Override
|
||||
public void handle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException, ServletException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException
|
||||
) throws IOException, ServletException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/src/main/java/com/openisle/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class OpenApiConfig {
|
||||
|
||||
private final SpringDocProperties springDocProperties;
|
||||
|
||||
@Value("${springdoc.info.title}")
|
||||
private String title;
|
||||
|
||||
@Value("${springdoc.info.description}")
|
||||
private String description;
|
||||
|
||||
@Value("${springdoc.info.version}")
|
||||
private String version;
|
||||
|
||||
@Value("${springdoc.info.scheme}")
|
||||
private String scheme;
|
||||
|
||||
@Value("${springdoc.info.header}")
|
||||
private String header;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
SecurityScheme securityScheme = new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme(scheme.toLowerCase())
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name(header);
|
||||
|
||||
List<Server> servers = springDocProperties
|
||||
.getServers()
|
||||
.stream()
|
||||
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new OpenAPI()
|
||||
.servers(servers)
|
||||
.info(new Info().title(title).description(description).version(version))
|
||||
.components(new Components().addSecuritySchemes("JWT", securityScheme))
|
||||
.addSecurityItem(new SecurityRequirement().addList("JWT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Initialize default point mall goods. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PointGoodInitializer implements CommandLineRunner {
|
||||
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (pointGoodRepository.count() == 0) {
|
||||
PointGood g1 = new PointGood();
|
||||
g1.setName("GPT Plus 1 个月");
|
||||
g1.setCost(20000);
|
||||
g1.setImage(
|
||||
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png"
|
||||
);
|
||||
pointGoodRepository.save(g1);
|
||||
|
||||
PointGood g2 = new PointGood();
|
||||
g2.setName("奶茶");
|
||||
g2.setCost(5000);
|
||||
g2.setImage(
|
||||
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png"
|
||||
);
|
||||
pointGoodRepository.save(g2);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
backend/src/main/java/com/openisle/config/RabbitMQConfig.java
Normal file
@@ -0,0 +1,219 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.TopicExchange;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RabbitMQConfig {
|
||||
|
||||
public static final String EXCHANGE_NAME = "openisle-exchange";
|
||||
// 保持向后兼容的常量
|
||||
public static final String QUEUE_NAME = "notifications-queue";
|
||||
public static final String ROUTING_KEY = "notifications.routingkey";
|
||||
|
||||
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
|
||||
private final int queueCount = 16;
|
||||
|
||||
@Value("${rabbitmq.queue.durable}")
|
||||
private boolean queueDurable;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TopicExchange exchange() {
|
||||
return new TopicExchange(EXCHANGE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有分片队列, 使用十六进制后缀 (0-f)
|
||||
*/
|
||||
@Bean
|
||||
public List<Queue> shardedQueues() {
|
||||
log.info("开始创建分片队列 Bean...");
|
||||
|
||||
List<Queue> queues = new ArrayList<>();
|
||||
for (int i = 0; i < queueCount; i++) {
|
||||
String shardKey = Integer.toHexString(i);
|
||||
String queueName = "notifications-queue-" + shardKey;
|
||||
Queue queue = new Queue(queueName, queueDurable);
|
||||
queues.add(queue);
|
||||
}
|
||||
|
||||
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
|
||||
return queues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
|
||||
*/
|
||||
@Bean
|
||||
public List<Binding> shardedBindings(
|
||||
TopicExchange exchange,
|
||||
@Qualifier("shardedQueues") List<Queue> shardedQueues
|
||||
) {
|
||||
log.info("开始创建分片绑定 Bean...");
|
||||
List<Binding> bindings = new ArrayList<>();
|
||||
if (shardedQueues != null) {
|
||||
for (Queue queue : shardedQueues) {
|
||||
String queueName = queue.getName();
|
||||
String shardKey = queueName.substring("notifications-queue-".length());
|
||||
String routingKey = "notifications.shard." + shardKey;
|
||||
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
|
||||
bindings.add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保持向后兼容的单队列配置(可选)
|
||||
*/
|
||||
@Bean
|
||||
public Queue legacyQueue() {
|
||||
return new Queue(QUEUE_NAME, queueDurable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保持向后兼容的单队列绑定(可选)
|
||||
*/
|
||||
@Bean
|
||||
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
|
||||
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2JsonMessageConverter messageConverter() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||
objectMapper.disable(
|
||||
com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
|
||||
);
|
||||
return new Jackson2JsonMessageConverter(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
|
||||
return new RabbitAdmin(connectionFactory);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
|
||||
RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||
template.setMessageConverter(messageConverter());
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
|
||||
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
|
||||
*/
|
||||
@Bean
|
||||
@DependsOn({ "rabbitAdmin", "shardedQueues", "exchange" })
|
||||
public CommandLineRunner queueDeclarationRunner(
|
||||
RabbitAdmin rabbitAdmin,
|
||||
@Qualifier("shardedQueues") List<Queue> shardedQueues,
|
||||
TopicExchange exchange,
|
||||
Queue legacyQueue,
|
||||
@Qualifier("shardedBindings") List<Binding> shardedBindings,
|
||||
Binding legacyBinding
|
||||
) {
|
||||
return args -> {
|
||||
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
|
||||
|
||||
try {
|
||||
// 声明交换
|
||||
rabbitAdmin.declareExchange(exchange);
|
||||
|
||||
// 声明分片队列 - 检查存在性
|
||||
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
|
||||
int successCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Queue queue : shardedQueues) {
|
||||
String queueName = queue.getName();
|
||||
try {
|
||||
// 使用 declareQueue 的返回值判断队列是否已存在
|
||||
// 如果队列已存在且配置匹配,declareQueue 会返回现有队列信息
|
||||
// 如果不匹配或不存在,会创建新队列
|
||||
rabbitAdmin.declareQueue(queue);
|
||||
successCount++;
|
||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
||||
if (
|
||||
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
|
||||
) {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info(
|
||||
"分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}",
|
||||
successCount,
|
||||
skippedCount,
|
||||
shardedQueues.size()
|
||||
);
|
||||
|
||||
// 声明分片绑定
|
||||
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
|
||||
int bindingSuccessCount = 0;
|
||||
for (Binding binding : shardedBindings) {
|
||||
try {
|
||||
rabbitAdmin.declareBinding(binding);
|
||||
bindingSuccessCount++;
|
||||
} catch (Exception e) {
|
||||
log.error("绑定声明失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
|
||||
|
||||
// 声明遗留队列和绑定 - 检查存在性
|
||||
try {
|
||||
rabbitAdmin.declareQueue(legacyQueue);
|
||||
rabbitAdmin.declareBinding(legacyBinding);
|
||||
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
|
||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
||||
if (
|
||||
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
|
||||
) {
|
||||
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
|
||||
} else {
|
||||
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
|
||||
}
|
||||
|
||||
log.info("=== RabbitMQ 组件声明完成 ===");
|
||||
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
||||
} catch (Exception e) {
|
||||
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Logs a message when a Redis connection is successfully established.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RedisConnectionLogger implements InitializingBean {
|
||||
|
||||
private final RedisConnectionFactory connectionFactory;
|
||||
|
||||
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
|
||||
this.connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
try (var connection = connectionFactory.getConnection()) {
|
||||
connection.ping();
|
||||
if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
|
||||
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
|
||||
} else {
|
||||
log.info("Redis connection established");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to connect to Redis", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,20 @@ package com.openisle.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulerConfig {
|
||||
@Bean
|
||||
public TaskScheduler taskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.setThreadNamePrefix("lottery-");
|
||||
scheduler.initialize();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TaskScheduler taskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.setThreadNamePrefix("lottery-");
|
||||
scheduler.initialize();
|
||||
return scheduler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.service.UserVisitService;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -21,173 +30,273 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||
private final UserVisitService userVisitService;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||
private final UserVisitService userVisitService;
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
return username -> userRepository.findByUsername(username)
|
||||
.<UserDetails>map(user -> org.springframework.security.core.userdetails.User
|
||||
.withUsername(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.authorities(user.getRole().name())
|
||||
.build())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
}
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
|
||||
return http.getSharedObject(AuthenticationManagerBuilder.class)
|
||||
.userDetailsService(userDetailsService)
|
||||
.passwordEncoder(passwordEncoder)
|
||||
.and()
|
||||
.build();
|
||||
}
|
||||
private final RedisTemplate redisTemplate;
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", cfg);
|
||||
return source;
|
||||
}
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/config/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll()
|
||||
.requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/stats/**").hasAuthority("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
return username ->
|
||||
userRepository
|
||||
.findByUsername(username)
|
||||
.<UserDetails>map(user ->
|
||||
org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.authorities(user.getRole().name())
|
||||
.build()
|
||||
)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OncePerRequestFilter jwtAuthenticationFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
// 让预检请求直接通过
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String uri = request.getRequestURI();
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(
|
||||
HttpSecurity http,
|
||||
PasswordEncoder passwordEncoder,
|
||||
UserDetailsService userDetailsService
|
||||
) throws Exception {
|
||||
return http
|
||||
.getSharedObject(AuthenticationManagerBuilder.class)
|
||||
.userDetailsService(userDetailsService)
|
||||
.passwordEncoder(passwordEncoder)
|
||||
.and()
|
||||
.build();
|
||||
}
|
||||
|
||||
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
|
||||
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
|
||||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
|
||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(
|
||||
List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://127.0.0.1:8082",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:3000",
|
||||
"http://frontend_dev:3000",
|
||||
"http://frontend_service:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.90",
|
||||
"http://192.168.7.90:3000",
|
||||
"https://petstore.swagger.io",
|
||||
// 允许自建OpenAPI地址
|
||||
"https://docs.open-isle.com",
|
||||
"https://www.docs.open-isle.com",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
)
|
||||
);
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
} catch (Exception e) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
return;
|
||||
}
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
||||
.authorizeHttpRequests(auth ->
|
||||
auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/ws/**", "/api/sockjs/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/v3/api-docs/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/categories/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/tags/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/config/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/google")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/reason")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/search/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/medals/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/push/public-key")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/rss")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/online/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/online/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/point-goods")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/point-goods")
|
||||
.permitAll()
|
||||
.requestMatchers("/actuator/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**")
|
||||
.authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/tags/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/stats/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.requestMatchers("/api/admin/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
@Bean
|
||||
public OncePerRequestFilter jwtAuthenticationFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
// 让预检请求直接通过
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
@Bean
|
||||
public OncePerRequestFilter userVisitFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
boolean publicGet =
|
||||
"GET".equalsIgnoreCase(request.getMethod()) &&
|
||||
(uri.startsWith("/api/posts") ||
|
||||
uri.startsWith("/api/comments") ||
|
||||
uri.startsWith("/api/categories") ||
|
||||
uri.startsWith("/api/tags") ||
|
||||
uri.startsWith("/api/search") ||
|
||||
uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") ||
|
||||
uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") ||
|
||||
uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/point-goods") ||
|
||||
uri.startsWith("/api/channels") ||
|
||||
uri.startsWith("/api/sitemap.xml") ||
|
||||
uri.startsWith("/api/medals") ||
|
||||
uri.startsWith("/actuator") ||
|
||||
uri.startsWith("/api/rss"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
userDetails,
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(
|
||||
authToken
|
||||
);
|
||||
} catch (Exception e) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
!uri.startsWith("/api/auth") &&
|
||||
!publicGet &&
|
||||
!uri.startsWith("/api/ws") &&
|
||||
!uri.startsWith("/api/sockjs") &&
|
||||
!uri.startsWith("/api/v3/api-docs") &&
|
||||
!uri.startsWith("/api/online")
|
||||
) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OncePerRequestFilter userVisitFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
var auth =
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||
if (
|
||||
auth != null &&
|
||||
auth.isAuthenticated() &&
|
||||
!(auth instanceof
|
||||
org.springframework.security.authentication.AnonymousAuthenticationToken)
|
||||
) {
|
||||
String key = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
|
||||
redisTemplate.opsForSet().add(key, auth.getName());
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/src/main/java/com/openisle/config/ShardInfo.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class ShardInfo {
|
||||
|
||||
private int shardIndex;
|
||||
private String queueName;
|
||||
private String routingKey;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ShardingStrategy {
|
||||
|
||||
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
|
||||
private static final int QUEUE_COUNT = 16;
|
||||
|
||||
// 分片分布统计
|
||||
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 根据用户名获取分片信息(基于哈希值首字符)
|
||||
*/
|
||||
public ShardInfo getShardInfo(String username) {
|
||||
if (username == null || username.isEmpty()) {
|
||||
// 空用户名默认分到第0个分片
|
||||
return getShardInfoByIndex(0);
|
||||
}
|
||||
|
||||
// 计算用户名的哈希值并转为十六进制字符串
|
||||
String hash = Integer.toHexString(Math.abs(username.hashCode()));
|
||||
|
||||
// 取哈希值的第一个字符 (0-9, a-f)
|
||||
char firstChar = hash.charAt(0);
|
||||
|
||||
// 十六进制字符映射到队列
|
||||
int shard = getShardFromHexChar(firstChar);
|
||||
recordShardUsage(shard);
|
||||
|
||||
log.debug(
|
||||
"Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
|
||||
username,
|
||||
hash,
|
||||
firstChar,
|
||||
shard
|
||||
);
|
||||
|
||||
return getShardInfoByIndex(shard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将十六进制字符映射到分片索引
|
||||
*/
|
||||
private int getShardFromHexChar(char hexChar) {
|
||||
int charValue;
|
||||
if (hexChar >= '0' && hexChar <= '9') {
|
||||
charValue = hexChar - '0'; // 0-9
|
||||
} else if (hexChar >= 'a' && hexChar <= 'f') {
|
||||
charValue = hexChar - 'a' + 10; // 10-15
|
||||
} else {
|
||||
// 异常情况,默认为0
|
||||
charValue = 0;
|
||||
}
|
||||
|
||||
// 映射到队列数量范围内
|
||||
return charValue % QUEUE_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分片索引获取分片信息
|
||||
*/
|
||||
private ShardInfo getShardInfoByIndex(int shard) {
|
||||
String shardKey = Integer.toHexString(shard);
|
||||
return new ShardInfo(
|
||||
shard,
|
||||
"notifications-queue-" + shardKey,
|
||||
"notifications.shard." + shardKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录分片使用统计
|
||||
*/
|
||||
private void recordShardUsage(int shard) {
|
||||
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "springdoc.api-docs")
|
||||
public class SpringDocProperties {
|
||||
|
||||
private List<ServerConfig> servers = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class ServerConfig {
|
||||
|
||||
private String url;
|
||||
private String description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Ensure a dedicated "system" user exists for internal operations.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SystemUserInitializer implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
userRepository
|
||||
.findByUsername("system")
|
||||
.orElseGet(() -> {
|
||||
User system = new User();
|
||||
system.setUsername("system");
|
||||
system.setEmail("system@openisle.local");
|
||||
// todo(tim): raw password 采用环境变量
|
||||
system.setPassword(passwordEncoder.encode("system"));
|
||||
system.setRole(Role.USER);
|
||||
system.setVerified(true);
|
||||
system.setApproved(true);
|
||||
system.setAvatar(
|
||||
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
);
|
||||
return userRepository.save(system);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,49 +9,75 @@ import com.openisle.model.ActivityType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.ActivityService;
|
||||
import com.openisle.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/activities")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityController {
|
||||
private final ActivityService activityService;
|
||||
private final UserService userService;
|
||||
private final ActivityMapper activityMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<ActivityDto> list() {
|
||||
return activityService.list().stream()
|
||||
.map(activityMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
private final ActivityService activityService;
|
||||
private final UserService userService;
|
||||
private final ActivityMapper activityMapper;
|
||||
|
||||
@GetMapping("/milk-tea")
|
||||
public MilkTeaInfoDto milkTea() {
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
long count = activityService.countParticipants(a);
|
||||
if (!a.isEnded() && count >= 50) {
|
||||
activityService.end(a);
|
||||
}
|
||||
MilkTeaInfoDto info = new MilkTeaInfoDto();
|
||||
info.setRedeemCount(count);
|
||||
info.setEnded(a.isEnded());
|
||||
return info;
|
||||
}
|
||||
@GetMapping
|
||||
@Operation(summary = "List activities", description = "Retrieve all activities")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of activities",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class)))
|
||||
)
|
||||
public List<ActivityDto> list() {
|
||||
return activityService.list().stream().map(activityMapper::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/milk-tea/redeem")
|
||||
public java.util.Map<String, String> redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
boolean first = activityService.redeem(a, user, req.getContact());
|
||||
if (first) {
|
||||
return java.util.Map.of("message", "redeemed");
|
||||
}
|
||||
return java.util.Map.of("message", "updated");
|
||||
@GetMapping("/milk-tea")
|
||||
@Operation(summary = "Milk tea info", description = "Get milk tea activity information")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Milk tea info",
|
||||
content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class))
|
||||
)
|
||||
public MilkTeaInfoDto milkTea() {
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
long count = activityService.countParticipants(a);
|
||||
if (!a.isEnded() && count >= 50) {
|
||||
activityService.end(a);
|
||||
}
|
||||
MilkTeaInfoDto info = new MilkTeaInfoDto();
|
||||
info.setRedeemCount(count);
|
||||
info.setEnded(a.isEnded());
|
||||
return info;
|
||||
}
|
||||
|
||||
@PostMapping("/milk-tea/redeem")
|
||||
@Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Redeem result",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public java.util.Map<String, String> redeemMilkTea(
|
||||
@RequestBody MilkTeaRedeemRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
||||
boolean first = activityService.redeem(a, user, req.getContact());
|
||||
if (first) {
|
||||
return java.util.Map.of("message", "redeemed");
|
||||
}
|
||||
return java.util.Map.of("message", "updated");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package com.openisle.controller;
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.service.CommentService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -14,16 +19,31 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/admin/comments")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminCommentController {
|
||||
private final CommentService commentService;
|
||||
private final CommentMapper commentMapper;
|
||||
|
||||
@PostMapping("/{id}/pin")
|
||||
public CommentDto pin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
private final CommentService commentService;
|
||||
private final CommentMapper commentMapper;
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
}
|
||||
@PostMapping("/{id}/pin")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Pin comment", description = "Pin a comment by its id")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Pinned comment",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
public CommentDto pin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Unpin comment", description = "Remove pin from a comment")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unpinned comment",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import com.openisle.service.AiUsageService;
|
||||
import com.openisle.service.PasswordValidator;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.RegisterModeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -12,36 +17,56 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/admin/config")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminConfigController {
|
||||
private final PostService postService;
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final RegisterModeService registerModeService;
|
||||
|
||||
@GetMapping
|
||||
public ConfigDto getConfig() {
|
||||
ConfigDto dto = new ConfigDto();
|
||||
dto.setPublishMode(postService.getPublishMode());
|
||||
dto.setPasswordStrength(passwordValidator.getStrength());
|
||||
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
|
||||
dto.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return dto;
|
||||
private final PostService postService;
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final RegisterModeService registerModeService;
|
||||
|
||||
@GetMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(
|
||||
summary = "Get configuration",
|
||||
description = "Retrieve application configuration settings"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Current configuration",
|
||||
content = @Content(schema = @Schema(implementation = ConfigDto.class))
|
||||
)
|
||||
public ConfigDto getConfig() {
|
||||
ConfigDto dto = new ConfigDto();
|
||||
dto.setPublishMode(postService.getPublishMode());
|
||||
dto.setPasswordStrength(passwordValidator.getStrength());
|
||||
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
|
||||
dto.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(
|
||||
summary = "Update configuration",
|
||||
description = "Update application configuration settings"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated configuration",
|
||||
content = @Content(schema = @Schema(implementation = ConfigDto.class))
|
||||
)
|
||||
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
|
||||
if (dto.getPublishMode() != null) {
|
||||
postService.setPublishMode(dto.getPublishMode());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
|
||||
if (dto.getPublishMode() != null) {
|
||||
postService.setPublishMode(dto.getPublishMode());
|
||||
}
|
||||
if (dto.getPasswordStrength() != null) {
|
||||
passwordValidator.setStrength(dto.getPasswordStrength());
|
||||
}
|
||||
if (dto.getAiFormatLimit() != null) {
|
||||
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
|
||||
}
|
||||
if (dto.getRegisterMode() != null) {
|
||||
registerModeService.setRegisterMode(dto.getRegisterMode());
|
||||
}
|
||||
return getConfig();
|
||||
if (dto.getPasswordStrength() != null) {
|
||||
passwordValidator.setStrength(dto.getPasswordStrength());
|
||||
}
|
||||
|
||||
if (dto.getAiFormatLimit() != null) {
|
||||
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
|
||||
}
|
||||
if (dto.getRegisterMode() != null) {
|
||||
registerModeService.setRegisterMode(dto.getRegisterMode());
|
||||
}
|
||||
return getConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Simple admin demo endpoint.
|
||||
*/
|
||||
@RestController
|
||||
public class AdminController {
|
||||
@GetMapping("/api/admin/hello")
|
||||
public Map<String, String> adminHello() {
|
||||
return Map.of("message", "Hello, Admin User");
|
||||
}
|
||||
|
||||
@GetMapping("/api/admin/hello")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Admin greeting", description = "Returns a greeting for admin users")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Greeting payload",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public Map<String, String> adminHello() {
|
||||
return Map.of("message", "Hello, Admin User");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@ package com.openisle.controller;
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Endpoints for administrators to manage posts.
|
||||
@@ -16,33 +21,109 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/admin/posts")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminPostController {
|
||||
private final PostService postService;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@GetMapping("/pending")
|
||||
public List<PostSummaryDto> pendingPosts() {
|
||||
return postService.listPendingPosts().stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
private final PostService postService;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public PostSummaryDto approve(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.approvePost(id));
|
||||
}
|
||||
@GetMapping("/pending")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Pending posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> pendingPosts() {
|
||||
return postService
|
||||
.listPendingPosts()
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public PostSummaryDto reject(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.rejectPost(id));
|
||||
}
|
||||
@PostMapping("/{id}/approve")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Approve post", description = "Approve a pending post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Approved post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto approve(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.approvePost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/pin")
|
||||
public PostSummaryDto pin(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.pinPost(id));
|
||||
}
|
||||
@PostMapping("/{id}/reject")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Reject post", description = "Reject a pending post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Rejected post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto reject(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.rejectPost(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
public PostSummaryDto unpin(@PathVariable Long id) {
|
||||
return postMapper.toSummaryDto(postService.unpinPost(id));
|
||||
}
|
||||
@PostMapping("/{id}/pin")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Pin post", description = "Pin a post to the top")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Pinned post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto pin(
|
||||
@PathVariable Long id,
|
||||
org.springframework.security.core.Authentication auth
|
||||
) {
|
||||
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Unpin post", description = "Remove a post from the top")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unpinned post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto unpin(
|
||||
@PathVariable Long id,
|
||||
org.springframework.security.core.Authentication auth
|
||||
) {
|
||||
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rss-exclude")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto excludeFromRss(
|
||||
@PathVariable Long id,
|
||||
org.springframework.security.core.Authentication auth
|
||||
) {
|
||||
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rss-include")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Include in RSS", description = "Include a post in the RSS feed")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto includeInRss(
|
||||
@PathVariable Long id,
|
||||
org.springframework.security.core.Authentication auth
|
||||
) {
|
||||
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,53 @@ import com.openisle.mapper.TagMapper;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.TagService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/tags")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminTagController {
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@GetMapping("/pending")
|
||||
public List<TagDto> pendingTags() {
|
||||
return tagService.listPendingTags().stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public TagDto approve(@PathVariable Long id) {
|
||||
Tag tag = tagService.approveTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
@GetMapping("/pending")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Pending tags",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||
)
|
||||
public List<TagDto> pendingTags() {
|
||||
return tagService
|
||||
.listPendingTags()
|
||||
.stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Approve tag", description = "Approve a pending tag")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Approved tag",
|
||||
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||
)
|
||||
public TagDto approve(@PathVariable Long id) {
|
||||
Tag tag = tagService.approveTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.openisle.controller;
|
||||
import com.openisle.model.Notification;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailSender;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -15,40 +18,56 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final EmailSender emailSender;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(true);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(user.getEmail(), "您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final EmailSender emailSender;
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(false);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
private void markRegisterRequestNotificationsRead(User applicant) {
|
||||
java.util.List<Notification> notifs =
|
||||
notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
|
||||
for (Notification n : notifs) {
|
||||
n.setRead(true);
|
||||
}
|
||||
notificationRepository.saveAll(notifs);
|
||||
@PostMapping("/{id}/approve")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Approve user", description = "Approve a pending user registration")
|
||||
@ApiResponse(responseCode = "200", description = "User approved")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(true);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||
);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Reject user", description = "Reject a pending user registration")
|
||||
@ApiResponse(responseCode = "200", description = "User rejected")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
user.setApproved(false);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||
);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private void markRegisterRequestNotificationsRead(User applicant) {
|
||||
java.util.List<Notification> notifs = notificationRepository.findByTypeAndFromUser(
|
||||
NotificationType.REGISTER_REQUEST,
|
||||
applicant
|
||||
);
|
||||
for (Notification n : notifs) {
|
||||
n.setRead(true);
|
||||
}
|
||||
notificationRepository.saveAll(notifs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.OpenAiService;
|
||||
import com.openisle.service.AiUsageService;
|
||||
import com.openisle.service.OpenAiService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -10,31 +16,39 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
@RequiredArgsConstructor
|
||||
public class AiController {
|
||||
|
||||
private final OpenAiService openAiService;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final OpenAiService openAiService;
|
||||
private final AiUsageService aiUsageService;
|
||||
|
||||
@PostMapping("/format")
|
||||
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
|
||||
Authentication auth) {
|
||||
String text = req.get("text");
|
||||
if (text == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
int limit = aiUsageService.getFormatLimit();
|
||||
int used = aiUsageService.getCount(auth.getName());
|
||||
if (limit > 0 && used >= limit) {
|
||||
return ResponseEntity.status(429).build();
|
||||
}
|
||||
aiUsageService.incrementAndGetCount(auth.getName());
|
||||
return openAiService.formatMarkdown(text)
|
||||
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
||||
.orElse(ResponseEntity.status(500).build());
|
||||
@PostMapping("/format")
|
||||
@Operation(summary = "Format markdown", description = "Format text via AI")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Formatted content",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<Map<String, String>> format(
|
||||
@RequestBody Map<String, String> req,
|
||||
Authentication auth
|
||||
) {
|
||||
String text = req.get("text");
|
||||
if (text == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
int limit = aiUsageService.getFormatLimit();
|
||||
int used = aiUsageService.getCount(auth.getName());
|
||||
if (limit > 0 && used >= limit) {
|
||||
return ResponseEntity.status(429).build();
|
||||
}
|
||||
aiUsageService.incrementAndGetCount(auth.getName());
|
||||
return openAiService
|
||||
.formatMarkdown(text)
|
||||
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
||||
.orElse(ResponseEntity.status(500).build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +1,710 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.dto.*;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.openisle.util.VerifyType;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
private final UserService userService;
|
||||
private final JwtService jwtService;
|
||||
private final EmailSender emailService;
|
||||
private final CaptchaService captchaService;
|
||||
private final GoogleAuthService googleAuthService;
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtService jwtService;
|
||||
private final EmailSender emailService;
|
||||
private final CaptchaService captchaService;
|
||||
private final GoogleAuthService googleAuthService;
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
private final InviteService inviteService;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
|
||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Register user", description = "Register a new user account")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Registration result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
|
||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
||||
InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken());
|
||||
if (!result.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
|
||||
}
|
||||
try {
|
||||
User user = userService.registerWithInvite(
|
||||
req.getUsername(),
|
||||
req.getEmail(),
|
||||
req.getPassword()
|
||||
);
|
||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
||||
// 发送确认邮件
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(user.getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("field", e.getField(), "error", e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
User user = userService.register(
|
||||
req.getUsername(),
|
||||
req.getEmail(),
|
||||
req.getPassword(),
|
||||
"",
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
// 发送确认邮件
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
if (!user.isApproved()) {
|
||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
@PostMapping("/verify")
|
||||
@Operation(summary = "Verify account", description = "Verify registration code")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Verification result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
||||
Optional<User> userOpt = userService.findByUsername(req.getUsername());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER);
|
||||
if (ok) {
|
||||
User user = userOpt.get();
|
||||
|
||||
if (user.isApproved()) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"Verified and isApproved",
|
||||
"reason_code",
|
||||
"VERIFIED_AND_APPROVED",
|
||||
"token",
|
||||
jwtService.generateToken(req.getUsername())
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"Verified",
|
||||
"reason_code",
|
||||
"VERIFIED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(req.getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Login", description = "Authenticate with username/email and password")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
|
||||
if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsername(req.getUsername());
|
||||
if (userOpt.isEmpty()) {
|
||||
userOpt = userService.findByEmail(req.getUsername());
|
||||
}
|
||||
if (userOpt.isEmpty() || !userService.matchesPassword(userOpt.get(), req.getPassword())) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid credentials", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!user.isVerified()) {
|
||||
user = userService.register(
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPassword(),
|
||||
user.getRegisterReason(),
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"User not verified",
|
||||
"reason_code",
|
||||
"NOT_VERIFIED",
|
||||
"user_name",
|
||||
user.getUsername()
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
RegisterMode.WHITELIST.equals(registerModeService.getRegisterMode()) && !user.isApproved()
|
||||
) {
|
||||
if (user.getRegisterReason() != null && !user.getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Account awaiting approval", "reason_code", "IS_APPROVING")
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Register reason not approved",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(user.getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.getUsername())));
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
@Operation(summary = "Login with Google", description = "Authenticate using Google account")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(
|
||||
req.getInviteToken()
|
||||
);
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||
req.getIdToken(),
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite
|
||||
);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(
|
||||
req.getInviteToken(),
|
||||
inviteValidateResult.getInviteToken().getInviter().getUsername()
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (
|
||||
result.getUser().getRegisterReason() != null &&
|
||||
!result.getUser().getRegisterReason().isEmpty()
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"IS_APPROVING",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
User user = userService.register(
|
||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
if (!user.isApproved()) {
|
||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid google token", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/reason")
|
||||
@Operation(
|
||||
summary = "Submit register reason",
|
||||
description = "Submit registration reason for approval"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Submission result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> reason(@RequestBody MakeReasonRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReason(req.getToken());
|
||||
Optional<User> userOpt = userService.findByUsername(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid token, Please re-login", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/verify")
|
||||
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
||||
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
|
||||
if (ok) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Verified",
|
||||
"token", jwtService.generateReasonToken(req.getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
if (req.getReason() == null || req.getReason().trim().length() <= 20) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Reason's length must longer than 20", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
|
||||
if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsername(req.getUsername());
|
||||
if (userOpt.isEmpty()) {
|
||||
userOpt = userService.findByEmail(req.getUsername());
|
||||
}
|
||||
if (userOpt.isEmpty() || !userService.matchesPassword(userOpt.get(), req.getPassword())) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid credentials",
|
||||
"reason_code", "INVALID_CREDENTIALS"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!user.isVerified()) {
|
||||
user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "User not verified",
|
||||
"reason_code", "NOT_VERIFIED",
|
||||
"user_name", user.getUsername()));
|
||||
}
|
||||
if (RegisterMode.WHITELIST.equals(registerModeService.getRegisterMode()) && !user.isApproved()) {
|
||||
if (user.getRegisterReason() != null && !user.getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING"
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Register reason not approved",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.getUsername())));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.getUsername())));
|
||||
User user = userOpt.get();
|
||||
if (user.isApproved() || registerModeService.getRegisterMode() == RegisterMode.DIRECT) {
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
user = userService.updateReason(user.getUsername(), req.getReason());
|
||||
notificationService.createRegisterRequestNotifications(user, req.getReason());
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid google token",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
@PostMapping("/github")
|
||||
@Operation(summary = "Login with GitHub", description = "Authenticate using GitHub account")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(
|
||||
req.getInviteToken()
|
||||
);
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/reason")
|
||||
public ResponseEntity<?> reason(@RequestBody MakeReasonRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReason(req.getToken());
|
||||
Optional<User> userOpt = userService.findByUsername(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid token, Please re-login",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite
|
||||
);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(
|
||||
req.getInviteToken(),
|
||||
inviteValidateResult.getInviteToken().getInviter().getUsername()
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (
|
||||
result.getUser().getRegisterReason() != null &&
|
||||
!result.getUser().getRegisterReason().isEmpty()
|
||||
) {
|
||||
// 已填写注册理由
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"IS_APPROVING",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.getReason() == null || req.getReason().trim().length() <= 20) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Reason's length must longer than 20",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (user.isApproved() || registerModeService.getRegisterMode() == RegisterMode.DIRECT) {
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
}
|
||||
|
||||
user = userService.updateReason(user.getUsername(), req.getReason());
|
||||
notificationService.createRegisterRequestNotifications(user, req.getReason());
|
||||
return ResponseEntity.ok().body(Map.of("valid", true));
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid github code", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/github")
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
// 已填写注册理由
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
@PostMapping("/discord")
|
||||
@Operation(summary = "Login with Discord", description = "Authenticate using Discord account")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(
|
||||
req.getInviteToken()
|
||||
);
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite
|
||||
);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(
|
||||
req.getInviteToken(),
|
||||
inviteValidateResult.getInviteToken().getInviter().getUsername()
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (
|
||||
result.getUser().getRegisterReason() != null &&
|
||||
!result.getUser().getRegisterReason().isEmpty()
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"IS_APPROVING",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid github code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid discord code", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/discord")
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
@PostMapping("/twitter")
|
||||
@Operation(summary = "Login with Twitter", description = "Authenticate using Twitter account")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(
|
||||
req.getInviteToken()
|
||||
);
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||
req.getCode(),
|
||||
req.getCodeVerifier(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite
|
||||
);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(
|
||||
req.getInviteToken(),
|
||||
inviteValidateResult.getInviteToken().getInviter().getUsername()
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (
|
||||
result.getUser().getRegisterReason() != null &&
|
||||
!result.getUser().getRegisterReason().isEmpty()
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"IS_APPROVING",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid discord code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/twitter")
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
Optional<User> user = twitterAuthService.authenticate(
|
||||
req.getCode(),
|
||||
req.getCodeVerifier(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid twitter code", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/telegram")
|
||||
@Operation(summary = "Login with Telegram", description = "Authenticate using Telegram data")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Authentication result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(
|
||||
req.getInviteToken()
|
||||
);
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
|
||||
req,
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite
|
||||
);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(
|
||||
req.getInviteToken(),
|
||||
inviteValidateResult.getInviteToken().getInviter().getUsername()
|
||||
);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code",
|
||||
"INVITE_APPROVED"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (
|
||||
result.getUser().getRegisterReason() != null &&
|
||||
!result.getUser().getRegisterReason().isEmpty()
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"IS_APPROVING",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid twitter code",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Account awaiting approval",
|
||||
"reason_code",
|
||||
"NOT_APPROVED",
|
||||
"token",
|
||||
jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
)
|
||||
);
|
||||
}
|
||||
return ResponseEntity.ok(
|
||||
Map.of("token", jwtService.generateToken(result.getUser().getUsername()))
|
||||
);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Invalid telegram data", "reason_code", "INVALID_CREDENTIALS")
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
@GetMapping("/check")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Check token", description = "Validate JWT token")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Token valid",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/send")
|
||||
@Operation(summary = "Send reset code", description = "Send verification code for password reset")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Sending result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> sendReset(@RequestBody ForgotPasswordRequest req) {
|
||||
Optional<User> userOpt = userService.findByEmail(req.getEmail());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/send")
|
||||
public ResponseEntity<?> sendReset(@RequestBody ForgotPasswordRequest req) {
|
||||
Optional<User> userOpt = userService.findByEmail(req.getEmail());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
String code = userService.generatePasswordResetCode(req.getEmail());
|
||||
emailService.sendEmail(req.getEmail(), "请填写验证码以重置密码", "您的验证码是" + code);
|
||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||
@PostMapping("/forgot/verify")
|
||||
@Operation(summary = "Verify reset code", description = "Verify password reset code")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Verification result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
|
||||
Optional<User> userOpt = userService.findByEmail(req.getEmail());
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/verify")
|
||||
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
|
||||
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
|
||||
if (ok) {
|
||||
String username = userService.findByEmail(req.getEmail()).get().getUsername();
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD);
|
||||
if (ok) {
|
||||
String username = userService.findByEmail(req.getEmail()).get().getUsername();
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReset(req.getToken());
|
||||
try {
|
||||
userService.updatePassword(username, req.getPassword());
|
||||
return ResponseEntity.ok(Map.of("message", "Password updated"));
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"field", e.getField(),
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
@PostMapping("/forgot/reset")
|
||||
@Operation(summary = "Reset password", description = "Reset user password after verification")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reset result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest req) {
|
||||
String username = jwtService.validateAndGetSubjectForReset(req.getToken());
|
||||
try {
|
||||
userService.updatePassword(username, req.getPassword());
|
||||
return ResponseEntity.ok(Map.of("message", "Password updated"));
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("field", e.getField(), "error", e.getMessage())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DTO classes moved to com.openisle.dto package
|
||||
// DTO classes moved to com.openisle.dto package
|
||||
}
|
||||
|
||||
@@ -8,66 +8,120 @@ import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.service.CategoryService;
|
||||
import com.openisle.service.PostService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryController {
|
||||
private final CategoryService categoryService;
|
||||
private final PostService postService;
|
||||
private final PostMapper postMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
@PostMapping
|
||||
public CategoryDto create(@RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
private final CategoryService categoryService;
|
||||
private final PostService postService;
|
||||
private final PostMapper postMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
@PostMapping
|
||||
@Operation(summary = "Create category", description = "Create a new category")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Created category",
|
||||
content = @Content(schema = @Schema(implementation = CategoryDto.class))
|
||||
)
|
||||
public CategoryDto create(@RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.createCategory(
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon()
|
||||
);
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable Long id) {
|
||||
categoryService.deleteCategory(id);
|
||||
}
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update category", description = "Update an existing category")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated category",
|
||||
content = @Content(schema = @Schema(implementation = CategoryDto.class))
|
||||
)
|
||||
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
|
||||
Category c = categoryService.updateCategory(
|
||||
id,
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon()
|
||||
);
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> list() {
|
||||
List<Category> all = categoryService.listCategories();
|
||||
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||
return all.stream()
|
||||
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete category", description = "Remove a category by id")
|
||||
@ApiResponse(responseCode = "200", description = "Category deleted")
|
||||
public void delete(@PathVariable Long id) {
|
||||
categoryService.deleteCategory(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CategoryDto get(@PathVariable Long id) {
|
||||
Category c = categoryService.getCategory(id);
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
@GetMapping
|
||||
@Operation(summary = "List categories", description = "Get all categories")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of categories",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class)))
|
||||
)
|
||||
public List<CategoryDto> list() {
|
||||
List<Category> all = categoryService.listCategories();
|
||||
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||
return all
|
||||
.stream()
|
||||
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return postService.listPostsByCategories(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get category", description = "Get category by id")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Category detail",
|
||||
content = @Content(schema = @Schema(implementation = CategoryDto.class))
|
||||
)
|
||||
public CategoryDto get(@PathVariable Long id) {
|
||||
Category c = categoryService.getCategory(id);
|
||||
long count = postService.countPostsByCategory(c.getId());
|
||||
return categoryMapper.toDto(c, count);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
@Operation(summary = "List posts by category", description = "Get posts under a category")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> listPostsByCategory(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize
|
||||
) {
|
||||
return postService
|
||||
.listPostsByCategories(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.ChannelDto;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.ChannelService;
|
||||
import com.openisle.service.MessageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository
|
||||
.findByUsername(auth.getName())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List channels", description = "List channels for the current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Channels",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<ChannelDto> listChannels(Authentication auth) {
|
||||
return channelService.listChannels(getCurrentUserId(auth));
|
||||
}
|
||||
|
||||
@PostMapping("/{channelId}/join")
|
||||
@Operation(summary = "Join channel", description = "Join a channel")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Joined channel",
|
||||
content = @Content(schema = @Schema(implementation = ChannelDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
|
||||
return channelService.joinChannel(channelId, getCurrentUserId(auth));
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
@Operation(summary = "Unread count", description = "Get unread channel count")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unread count",
|
||||
content = @Content(schema = @Schema(implementation = Long.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public long unreadCount(Authentication auth) {
|
||||
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.dto.CommentRequest;
|
||||
import com.openisle.dto.PostChangeLogDto;
|
||||
import com.openisle.dto.TimelineItemDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.service.CaptchaService;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.service.LevelService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.mapper.PostChangeLogMapper;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -15,86 +27,196 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CommentController {
|
||||
private final CommentService commentService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final CommentMapper commentMapper;
|
||||
private final PointService pointService;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
private final CommentService commentService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final CommentMapper commentMapper;
|
||||
private final PointService pointService;
|
||||
private final PostChangeLogService changeLogService;
|
||||
private final PostChangeLogMapper postChangeLogMapper;
|
||||
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@PostMapping("/posts/{postId}/comments")
|
||||
public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth) {
|
||||
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
|
||||
CommentDto dto = commentMapper.toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
|
||||
log.debug("createComment succeeded for comment {}", comment.getId());
|
||||
return ResponseEntity.ok(dto);
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
|
||||
@PostMapping("/posts/{postId}/comments")
|
||||
@Operation(summary = "Create comment", description = "Add a comment to a post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Created comment",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<CommentDto> createComment(
|
||||
@PathVariable Long postId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
|
||||
CommentDto dto = commentMapper.toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
|
||||
log.debug("createComment succeeded for comment {}", comment.getId());
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}/replies")
|
||||
public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth) {
|
||||
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
|
||||
CommentDto dto = commentMapper.toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
log.debug("replyComment succeeded for comment {}", comment.getId());
|
||||
return ResponseEntity.ok(dto);
|
||||
@PostMapping("/comments/{commentId}/replies")
|
||||
@Operation(summary = "Reply to comment", description = "Reply to an existing comment")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reply created",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<CommentDto> replyComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestBody CommentRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
|
||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
|
||||
CommentDto dto = commentMapper.toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
log.debug("replyComment succeeded for comment {}", comment.getId());
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{postId}/comments")
|
||||
public List<CommentDto> listComments(@PathVariable Long postId,
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) {
|
||||
log.debug("listComments called for post {} with sort {}", postId, sort);
|
||||
List<CommentDto> list = commentService.getCommentsForPost(postId, sort).stream()
|
||||
.map(commentMapper::toDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
log.debug("listComments returning {} comments", list.size());
|
||||
return list;
|
||||
}
|
||||
@GetMapping("/posts/{postId}/comments")
|
||||
@Operation(summary = "List comments", description = "List comments for a post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Comments",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))
|
||||
)
|
||||
)
|
||||
public List<TimelineItemDto<?>> listComments(
|
||||
@PathVariable Long postId,
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
|
||||
) {
|
||||
log.debug("listComments called for post {} with sort {}", postId, sort);
|
||||
List<CommentDto> commentDtoList = commentService
|
||||
.getCommentsForPost(postId, sort)
|
||||
.stream()
|
||||
.map(commentMapper::toDtoWithReplies)
|
||||
.collect(Collectors.toList());
|
||||
List<PostChangeLogDto> postChangeLogDtoList = changeLogService
|
||||
.listLogs(postId)
|
||||
.stream()
|
||||
.map(postChangeLogMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
List<TimelineItemDto<?>> itemDtoList = new ArrayList<>();
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
public void deleteComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
|
||||
commentService.deleteComment(auth.getName(), id);
|
||||
log.debug("deleteComment completed for comment {}", id);
|
||||
}
|
||||
itemDtoList.addAll(
|
||||
commentDtoList
|
||||
.stream()
|
||||
.map(c ->
|
||||
new TimelineItemDto<>(
|
||||
c.getId(),
|
||||
"comment",
|
||||
c.getCreatedAt(),
|
||||
c.getPinnedAt(),
|
||||
c // payload 是 CommentDto
|
||||
)
|
||||
)
|
||||
.toList()
|
||||
);
|
||||
|
||||
@PostMapping("/comments/{id}/pin")
|
||||
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
itemDtoList.addAll(
|
||||
postChangeLogDtoList
|
||||
.stream()
|
||||
.map(l ->
|
||||
new TimelineItemDto<>(
|
||||
l.getId(),
|
||||
"log",
|
||||
l.getTime(), // 注意字段名不一样
|
||||
null,
|
||||
l // payload 是 PostChangeLogDto
|
||||
)
|
||||
)
|
||||
.toList()
|
||||
);
|
||||
// 排序
|
||||
Comparator<TimelineItemDto<?>> pinnedOrderComparator = (a, b) -> {
|
||||
LocalDateTime aPinned = a.getPinnedAt();
|
||||
LocalDateTime bPinned = b.getPinnedAt();
|
||||
if (aPinned == null && bPinned == null) {
|
||||
return 0;
|
||||
}
|
||||
if (aPinned == null) {
|
||||
return 1;
|
||||
}
|
||||
if (bPinned == null) {
|
||||
return -1;
|
||||
}
|
||||
return bPinned.compareTo(aPinned);
|
||||
};
|
||||
|
||||
@PostMapping("/comments/{id}/unpin")
|
||||
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
Comparator<TimelineItemDto<?>> comparator = Comparator.<TimelineItemDto<?>, Boolean>comparing(
|
||||
item -> item.getPinnedAt() == null
|
||||
).thenComparing(pinnedOrderComparator);
|
||||
|
||||
Comparator<TimelineItemDto<?>> createdAtComparator = Comparator.comparing(
|
||||
TimelineItemDto::getCreatedAt
|
||||
);
|
||||
if (CommentSort.NEWEST.equals(sort)) {
|
||||
createdAtComparator = createdAtComparator.reversed();
|
||||
}
|
||||
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
|
||||
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||
return itemDtoList;
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void deleteComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
|
||||
commentService.deleteComment(auth.getName(), id);
|
||||
log.debug("deleteComment completed for comment {}", id);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{id}/pin")
|
||||
@Operation(summary = "Pin comment", description = "Pin a comment")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Pinned comment",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{id}/unpin")
|
||||
@Operation(summary = "Unpin comment", description = "Unpin a comment")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unpinned comment",
|
||||
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.SiteConfigDto;
|
||||
import com.openisle.service.RegisterModeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -12,36 +16,42 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@lombok.RequiredArgsConstructor
|
||||
public class ConfigController {
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
@Value("${app.captcha.register-enabled:false}")
|
||||
private boolean registerCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
@Value("${app.captcha.login-enabled:false}")
|
||||
private boolean loginCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
@Value("${app.captcha.comment-enabled:false}")
|
||||
private boolean commentCaptchaEnabled;
|
||||
|
||||
@Value("${app.ai.format-limit:3}")
|
||||
private int aiFormatLimit;
|
||||
@Value("${app.ai.format-limit:3}")
|
||||
private int aiFormatLimit;
|
||||
|
||||
private final RegisterModeService registerModeService;
|
||||
private final RegisterModeService registerModeService;
|
||||
|
||||
@GetMapping("/config")
|
||||
public SiteConfigDto getConfig() {
|
||||
SiteConfigDto resp = new SiteConfigDto();
|
||||
resp.setCaptchaEnabled(captchaEnabled);
|
||||
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
|
||||
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
||||
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
||||
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
||||
resp.setAiFormatLimit(aiFormatLimit);
|
||||
resp.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return resp;
|
||||
}
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "Site config", description = "Get site configuration")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Site configuration",
|
||||
content = @Content(schema = @Schema(implementation = SiteConfigDto.class))
|
||||
)
|
||||
public SiteConfigDto getConfig() {
|
||||
SiteConfigDto resp = new SiteConfigDto();
|
||||
resp.setCaptchaEnabled(captchaEnabled);
|
||||
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
|
||||
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
||||
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
||||
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
||||
resp.setAiFormatLimit(aiFormatLimit);
|
||||
resp.setRegisterMode(registerModeService.getRegisterMode());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import com.openisle.dto.DraftRequest;
|
||||
import com.openisle.mapper.DraftMapper;
|
||||
import com.openisle.model.Draft;
|
||||
import com.openisle.service.DraftService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -14,25 +19,50 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/drafts")
|
||||
@RequiredArgsConstructor
|
||||
public class DraftController {
|
||||
private final DraftService draftService;
|
||||
private final DraftMapper draftMapper;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
|
||||
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
|
||||
return ResponseEntity.ok(draftMapper.toDto(draft));
|
||||
}
|
||||
private final DraftService draftService;
|
||||
private final DraftMapper draftMapper;
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
|
||||
return draftService.getDraft(auth.getName())
|
||||
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
@PostMapping
|
||||
@Operation(summary = "Save draft", description = "Save a draft for current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Draft saved",
|
||||
content = @Content(schema = @Schema(implementation = DraftDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
|
||||
Draft draft = draftService.saveDraft(
|
||||
auth.getName(),
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
);
|
||||
return ResponseEntity.ok(draftMapper.toDto(draft));
|
||||
}
|
||||
|
||||
@DeleteMapping("/me")
|
||||
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
|
||||
draftService.deleteDraft(auth.getName());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "Get my draft", description = "Get current user's draft")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Draft details",
|
||||
content = @Content(schema = @Schema(implementation = DraftDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
|
||||
return draftService
|
||||
.getDraft(auth.getName())
|
||||
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/me")
|
||||
@Operation(summary = "Delete my draft", description = "Delete current user's draft")
|
||||
@ApiResponse(responseCode = "200", description = "Draft deleted")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
|
||||
draftService.deleteDraft(auth.getName());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(FieldException.class)
|
||||
public ResponseEntity<?> handleFieldException(FieldException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", ex.getMessage(), "field", ex.getField()));
|
||||
}
|
||||
@ExceptionHandler(FieldException.class)
|
||||
public ResponseEntity<?> handleFieldException(FieldException ex) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", ex.getMessage(), "field", ex.getField())
|
||||
);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
|
||||
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
|
||||
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(RateLimitException.class)
|
||||
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
|
||||
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
@ExceptionHandler(RateLimitException.class)
|
||||
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
|
||||
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleException(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
if (message == null) {
|
||||
message = ex.getClass().getSimpleName();
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", message));
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleException(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
if (message == null) {
|
||||
message = ex.getClass().getSimpleName();
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class HelloController {
|
||||
@GetMapping("/api/hello")
|
||||
public Map<String, String> hello() {
|
||||
return Map.of("message", "Hello, Authenticated User");
|
||||
}
|
||||
|
||||
@GetMapping("/api/hello")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Greeting payload",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public Map<String, String> hello() {
|
||||
return Map.of("message", "Hello, Authenticated User");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.InviteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/invite")
|
||||
@RequiredArgsConstructor
|
||||
public class InviteController {
|
||||
|
||||
private final InviteService inviteService;
|
||||
|
||||
@PostMapping("/generate")
|
||||
@Operation(summary = "Generate invite", description = "Generate an invite token")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Invite token",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public Map<String, String> generate(Authentication auth) {
|
||||
String token = inviteService.generate(auth.getName());
|
||||
return Map.of("token", token);
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,49 @@ package com.openisle.controller;
|
||||
import com.openisle.dto.MedalDto;
|
||||
import com.openisle.dto.MedalSelectRequest;
|
||||
import com.openisle.service.MedalService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/medals")
|
||||
@RequiredArgsConstructor
|
||||
public class MedalController {
|
||||
private final MedalService medalService;
|
||||
|
||||
@GetMapping
|
||||
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
|
||||
return medalService.getMedals(userId);
|
||||
}
|
||||
private final MedalService medalService;
|
||||
|
||||
@PostMapping("/select")
|
||||
public ResponseEntity<Void> selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) {
|
||||
try {
|
||||
medalService.selectMedal(auth.getName(), req.getType());
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@GetMapping
|
||||
@Operation(summary = "List medals", description = "List medals for user or globally")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of medals",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class)))
|
||||
)
|
||||
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
|
||||
return medalService.getMedals(userId);
|
||||
}
|
||||
|
||||
@PostMapping("/select")
|
||||
@Operation(summary = "Select medal", description = "Select a medal for current user")
|
||||
@ApiResponse(responseCode = "200", description = "Medal selected")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<Void> selectMedal(
|
||||
@RequestBody MedalSelectRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
try {
|
||||
medalService.selectMedal(auth.getName(), req.getType());
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.ConversationDetailDto;
|
||||
import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.CreateConversationRequest;
|
||||
import com.openisle.dto.CreateConversationResponse;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.MessageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
// This is a placeholder for getting the current user's ID
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository
|
||||
.findByUsername(auth.getName())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
// In a real application, you would get this from the Authentication object
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/conversations")
|
||||
@Operation(summary = "List conversations", description = "Get all conversations of current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of conversations",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
|
||||
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
|
||||
return ResponseEntity.ok(conversations);
|
||||
}
|
||||
|
||||
@GetMapping("/conversations/{conversationId}")
|
||||
@Operation(summary = "Get conversation", description = "Get messages of a conversation")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Conversation detail",
|
||||
content = @Content(schema = @Schema(implementation = ConversationDetailDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<ConversationDetailDto> getMessages(
|
||||
@PathVariable Long conversationId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
Authentication auth
|
||||
) {
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
ConversationDetailDto conversationDetails = messageService.getConversationDetails(
|
||||
conversationId,
|
||||
getCurrentUserId(auth),
|
||||
pageable
|
||||
);
|
||||
return ResponseEntity.ok(conversationDetails);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Send message", description = "Send a direct message to a user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Message sent",
|
||||
content = @Content(schema = @Schema(implementation = MessageDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<MessageDto> sendMessage(
|
||||
@RequestBody MessageRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Message message = messageService.sendMessage(
|
||||
getCurrentUserId(auth),
|
||||
req.getRecipientId(),
|
||||
req.getContent(),
|
||||
req.getReplyToId()
|
||||
);
|
||||
return ResponseEntity.ok(messageService.toDto(message));
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{conversationId}/messages")
|
||||
@Operation(summary = "Send message to conversation", description = "Reply within a conversation")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Message sent",
|
||||
content = @Content(schema = @Schema(implementation = MessageDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<MessageDto> sendMessageToConversation(
|
||||
@PathVariable Long conversationId,
|
||||
@RequestBody ChannelMessageRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Message message = messageService.sendMessageToConversation(
|
||||
getCurrentUserId(auth),
|
||||
conversationId,
|
||||
req.getContent(),
|
||||
req.getReplyToId()
|
||||
);
|
||||
return ResponseEntity.ok(messageService.toDto(message));
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{conversationId}/read")
|
||||
@Operation(
|
||||
summary = "Mark conversation read",
|
||||
description = "Mark messages in conversation as read"
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Marked as read")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
|
||||
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/conversations")
|
||||
@Operation(
|
||||
summary = "Find or create conversation",
|
||||
description = "Find existing or create new conversation with recipient"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Conversation id",
|
||||
content = @Content(schema = @Schema(implementation = CreateConversationResponse.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(
|
||||
@RequestBody CreateConversationRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
MessageConversation conversation = messageService.findOrCreateConversation(
|
||||
getCurrentUserId(auth),
|
||||
req.getRecipientId()
|
||||
);
|
||||
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
@Operation(
|
||||
summary = "Unread message count",
|
||||
description = "Get unread message count for current user"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unread count",
|
||||
content = @Content(schema = @Schema(implementation = Long.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
|
||||
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
|
||||
}
|
||||
|
||||
// A simple request DTO
|
||||
static class MessageRequest {
|
||||
|
||||
private Long recipientId;
|
||||
private String content;
|
||||
private Long replyToId;
|
||||
|
||||
public Long getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public void setRecipientId(Long recipientId) {
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getReplyToId() {
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
public void setReplyToId(Long replyToId) {
|
||||
this.replyToId = replyToId;
|
||||
}
|
||||
}
|
||||
|
||||
static class ChannelMessageRequest {
|
||||
|
||||
private String content;
|
||||
private Long replyToId;
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getReplyToId() {
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
public void setReplyToId(Long replyToId) {
|
||||
this.replyToId = replyToId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,54 +2,158 @@ package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.NotificationDto;
|
||||
import com.openisle.dto.NotificationMarkReadRequest;
|
||||
import com.openisle.dto.NotificationUnreadCountDto;
|
||||
import com.openisle.dto.NotificationPreferenceDto;
|
||||
import com.openisle.dto.NotificationPreferenceUpdateRequest;
|
||||
import com.openisle.dto.NotificationUnreadCountDto;
|
||||
import com.openisle.mapper.NotificationMapper;
|
||||
import com.openisle.service.NotificationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Endpoints for user notifications. */
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationController {
|
||||
private final NotificationService notificationService;
|
||||
private final NotificationMapper notificationMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<NotificationDto> list(@RequestParam(value = "read", required = false) Boolean read,
|
||||
Authentication auth) {
|
||||
return notificationService.listNotifications(auth.getName(), read).stream()
|
||||
.map(notificationMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
private final NotificationService notificationService;
|
||||
private final NotificationMapper notificationMapper;
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public NotificationUnreadCountDto unreadCount(Authentication auth) {
|
||||
long count = notificationService.countUnread(auth.getName());
|
||||
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
|
||||
uc.setCount(count);
|
||||
return uc;
|
||||
}
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List notifications",
|
||||
description = "Retrieve notifications for the current user"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Notifications",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<NotificationDto> list(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "30") int size,
|
||||
Authentication auth
|
||||
) {
|
||||
return notificationService
|
||||
.listNotifications(auth.getName(), null, page, size)
|
||||
.stream()
|
||||
.map(notificationMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/read")
|
||||
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
|
||||
notificationService.markRead(auth.getName(), req.getIds());
|
||||
}
|
||||
@GetMapping("/unread")
|
||||
@Operation(
|
||||
summary = "List unread notifications",
|
||||
description = "Retrieve unread notifications for the current user"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unread notifications",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<NotificationDto> listUnread(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "30") int size,
|
||||
Authentication auth
|
||||
) {
|
||||
return notificationService
|
||||
.listNotifications(auth.getName(), false, page, size)
|
||||
.stream()
|
||||
.map(notificationMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/prefs")
|
||||
public List<NotificationPreferenceDto> prefs(Authentication auth) {
|
||||
return notificationService.listPreferences(auth.getName());
|
||||
}
|
||||
@GetMapping("/unread-count")
|
||||
@Operation(summary = "Unread count", description = "Get count of unread notifications")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Unread count",
|
||||
content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public NotificationUnreadCountDto unreadCount(Authentication auth) {
|
||||
long count = notificationService.countUnread(auth.getName());
|
||||
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
|
||||
uc.setCount(count);
|
||||
return uc;
|
||||
}
|
||||
|
||||
@PostMapping("/prefs")
|
||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
@PostMapping("/read")
|
||||
@Operation(summary = "Mark notifications read", description = "Mark notifications as read")
|
||||
@ApiResponse(responseCode = "200", description = "Marked read")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
|
||||
notificationService.markRead(auth.getName(), req.getIds());
|
||||
}
|
||||
|
||||
@GetMapping("/prefs")
|
||||
@Operation(summary = "List preferences", description = "List notification preferences")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Preferences",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<NotificationPreferenceDto> prefs(Authentication auth) {
|
||||
return notificationService.listPreferences(auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/prefs")
|
||||
@Operation(summary = "Update preference", description = "Update notification preference")
|
||||
@ApiResponse(responseCode = "200", description = "Preference updated")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void updatePref(
|
||||
@RequestBody NotificationPreferenceUpdateRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
|
||||
@GetMapping("/email-prefs")
|
||||
@Operation(
|
||||
summary = "List email preferences",
|
||||
description = "List email notification preferences"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Email preferences",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
|
||||
return notificationService.listEmailPreferences(auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/email-prefs")
|
||||
@Operation(
|
||||
summary = "Update email preference",
|
||||
description = "Update email notification preference"
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Email preference updated")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void updateEmailPref(
|
||||
@RequestBody NotificationPreferenceUpdateRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.time.Duration;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* @author smallclover
|
||||
* @since 2025-09-05
|
||||
* 统计在线人数
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/online")
|
||||
@RequiredArgsConstructor
|
||||
public class OnlineController {
|
||||
|
||||
private final RedisTemplate redisTemplate;
|
||||
private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME + ":";
|
||||
|
||||
@PostMapping("/heartbeat")
|
||||
@Operation(summary = "Heartbeat", description = "Record user heartbeat")
|
||||
@ApiResponse(responseCode = "200", description = "Heartbeat recorded")
|
||||
public void ping(@RequestParam String userId) {
|
||||
redisTemplate.opsForValue().set(ONLINE_KEY + userId, "1", Duration.ofSeconds(150));
|
||||
}
|
||||
|
||||
@GetMapping("/count")
|
||||
@Operation(summary = "Online count", description = "Get current online user count")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Online count",
|
||||
content = @Content(schema = @Schema(implementation = Long.class))
|
||||
)
|
||||
public long count() {
|
||||
return redisTemplate.keys(ONLINE_KEY + "*").size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointHistoryDto;
|
||||
import com.openisle.mapper.PointHistoryMapper;
|
||||
import com.openisle.service.PointService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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
|
||||
@RequestMapping("/api/point-histories")
|
||||
@RequiredArgsConstructor
|
||||
public class PointHistoryController {
|
||||
|
||||
private final PointService pointService;
|
||||
private final PointHistoryMapper pointHistoryMapper;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Point history", description = "List point history for current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of point histories",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class))
|
||||
)
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<PointHistoryDto> list(Authentication auth) {
|
||||
return pointService
|
||||
.listHistory(auth.getName())
|
||||
.stream()
|
||||
.map(pointHistoryMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/trend")
|
||||
@Operation(summary = "Point trend", description = "Get point trend data for current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Trend data",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public List<Map<String, Object>> trend(
|
||||
Authentication auth,
|
||||
@RequestParam(value = "days", defaultValue = "30") int days
|
||||
) {
|
||||
return pointService.trend(auth.getName(), days);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.dto.PointRedeemRequest;
|
||||
import com.openisle.mapper.PointGoodMapper;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.PointMallService;
|
||||
import com.openisle.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** REST controller for point mall. */
|
||||
@RestController
|
||||
@RequestMapping("/api/point-goods")
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallController {
|
||||
|
||||
private final PointMallService pointMallService;
|
||||
private final UserService userService;
|
||||
private final PointGoodMapper pointGoodMapper;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List goods", description = "List all point goods")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of goods",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class)))
|
||||
)
|
||||
public List<PointGoodDto> list() {
|
||||
return pointMallService
|
||||
.listGoods()
|
||||
.stream()
|
||||
.map(pointGoodMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/redeem")
|
||||
@Operation(summary = "Redeem good", description = "Redeem a point good")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Remaining points",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
||||
return Map.of("point", point);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PostChangeLogDto;
|
||||
import com.openisle.mapper.PostChangeLogMapper;
|
||||
import com.openisle.service.PostChangeLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
@RequiredArgsConstructor
|
||||
public class PostChangeLogController {
|
||||
|
||||
private final PostChangeLogService changeLogService;
|
||||
private final PostChangeLogMapper mapper;
|
||||
|
||||
@GetMapping("/{id}/change-logs")
|
||||
@Operation(summary = "Post change logs", description = "List change logs for a post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Change logs",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
|
||||
return changeLogService.listLogs(id).stream().map(mapper::toDto).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,318 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.dto.PollDto;
|
||||
import com.openisle.dto.PostDetailDto;
|
||||
import com.openisle.dto.PostRequest;
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts")
|
||||
@RequiredArgsConstructor
|
||||
public class PostController {
|
||||
private final PostService postService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final DraftService draftService;
|
||||
private final UserVisitService userVisitService;
|
||||
private final PostMapper postMapper;
|
||||
private final PointService pointService;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
private final PostService postService;
|
||||
private final CategoryService categoryService;
|
||||
private final TagService tagService;
|
||||
private final LevelService levelService;
|
||||
private final CaptchaService captchaService;
|
||||
private final DraftService draftService;
|
||||
private final UserVisitService userVisitService;
|
||||
private final PostMapper postMapper;
|
||||
private final PointService pointService;
|
||||
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PostDetailDto> createPost(@RequestBody PostRequest req, Authentication auth) {
|
||||
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
||||
req.getTitle(), req.getContent(), req.getTagIds(),
|
||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||
req.getPrizeCount(), req.getStartTime(), req.getEndTime());
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForPost(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
@Value("${app.captcha.post-enabled:false}")
|
||||
private boolean postCaptchaEnabled;
|
||||
|
||||
@PostMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Create post", description = "Create a new post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Created post",
|
||||
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||
)
|
||||
public ResponseEntity<PostDetailDto> createPost(
|
||||
@RequestBody PostRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
Post post = postService.createPost(
|
||||
auth.getName(),
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
req.getPointCost(),
|
||||
req.getStartTime(),
|
||||
req.getEndTime(),
|
||||
req.getOptions(),
|
||||
req.getMultiple()
|
||||
);
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<PostDetailDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
|
||||
Authentication auth) {
|
||||
Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
|
||||
req.getTitle(), req.getContent(), req.getTagIds());
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
@PutMapping("/{id}")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Update post", description = "Update an existing post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated post",
|
||||
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||
)
|
||||
public ResponseEntity<PostDetailDto> updatePost(
|
||||
@PathVariable Long id,
|
||||
@RequestBody PostRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Post post = postService.updatePost(
|
||||
id,
|
||||
auth.getName(),
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void deletePost(@PathVariable Long id, Authentication auth) {
|
||||
postService.deletePost(id, auth.getName());
|
||||
}
|
||||
@DeleteMapping("/{id}")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Delete post", description = "Delete a post")
|
||||
@ApiResponse(responseCode = "200", description = "Post deleted")
|
||||
public void deletePost(@PathVariable Long id, Authentication auth) {
|
||||
postService.deletePost(id, auth.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||
String viewer = auth != null ? auth.getName() : null;
|
||||
Post post = postService.viewPost(id, viewer);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
|
||||
}
|
||||
@PostMapping("/{id}/close")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Close post", description = "Close a post to prevent further replies")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Closed post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
|
||||
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/lottery/join")
|
||||
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
|
||||
postService.joinLottery(id, auth.getName());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
@PostMapping("/{id}/reopen")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Reopen post", description = "Reopen a closed post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reopened post",
|
||||
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
|
||||
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth) {
|
||||
List<Long> ids = categoryIds;
|
||||
if (categoryId != null) {
|
||||
ids = java.util.List.of(categoryId);
|
||||
}
|
||||
List<Long> tids = tagIds;
|
||||
if (tagId != null) {
|
||||
tids = java.util.List.of(tagId);
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get post", description = "Get post details by id")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Post detail",
|
||||
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||
)
|
||||
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||
String viewer = auth != null ? auth.getName() : null;
|
||||
Post post = postService.viewPost(id, viewer);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
|
||||
}
|
||||
|
||||
if (auth != null) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
@PostMapping("/{id}/lottery/join")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Join lottery", description = "Join a lottery for the post")
|
||||
@ApiResponse(responseCode = "200", description = "Joined lottery")
|
||||
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
|
||||
postService.joinLottery(id, auth.getName());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
boolean hasCategories = ids != null && !ids.isEmpty();
|
||||
boolean hasTags = tids != null && !tids.isEmpty();
|
||||
@GetMapping("/{id}/poll/progress")
|
||||
@Operation(summary = "Poll progress", description = "Get poll progress for a post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Poll progress",
|
||||
content = @Content(schema = @Schema(implementation = PollDto.class))
|
||||
)
|
||||
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
|
||||
}
|
||||
|
||||
if (hasCategories && hasTags) {
|
||||
return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
if (hasTags) {
|
||||
return postService.listPostsByTags(tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
@PostMapping("/{id}/poll/vote")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Vote poll", description = "Vote on a poll option")
|
||||
@ApiResponse(responseCode = "200", description = "Vote recorded")
|
||||
public ResponseEntity<Void> vote(
|
||||
@PathVariable Long id,
|
||||
@RequestParam("option") List<Integer> option,
|
||||
Authentication auth
|
||||
) {
|
||||
postService.votePoll(id, auth.getName(), option);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
return postService.listPostsByCategories(ids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping
|
||||
@Operation(summary = "List posts", description = "List posts by various filters")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
@Cacheable(
|
||||
value = CachingConfig.POST_CACHE_NAME,
|
||||
key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)"
|
||||
)
|
||||
public List<PostSummaryDto> listPosts(
|
||||
@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth
|
||||
) {
|
||||
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
|
||||
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
|
||||
// 只需要在请求的一开始统计一次
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
@GetMapping("/ranking")
|
||||
public List<PostSummaryDto> rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth) {
|
||||
List<Long> ids = categoryIds;
|
||||
if (categoryId != null) {
|
||||
ids = java.util.List.of(categoryId);
|
||||
}
|
||||
List<Long> tids = tagIds;
|
||||
if (tagId != null) {
|
||||
tids = java.util.List.of(tagId);
|
||||
}
|
||||
return postService
|
||||
.defaultListPosts(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (auth != null) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
@GetMapping("/ranking")
|
||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Ranked posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> rankingPosts(
|
||||
@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth
|
||||
) {
|
||||
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
|
||||
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
|
||||
// 只需要在请求的一开始统计一次
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
return postService.listPostsByViews(ids, tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
return postService
|
||||
.listPostsByViews(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/latest-reply")
|
||||
public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth) {
|
||||
List<Long> ids = categoryIds;
|
||||
if (categoryId != null) {
|
||||
ids = java.util.List.of(categoryId);
|
||||
}
|
||||
List<Long> tids = tagIds;
|
||||
if (tagId != null) {
|
||||
tids = java.util.List.of(tagId);
|
||||
}
|
||||
@GetMapping("/latest-reply")
|
||||
@Operation(summary = "Latest reply posts", description = "List posts by latest replies")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Posts sorted by latest reply",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
@Cacheable(
|
||||
value = CachingConfig.POST_CACHE_NAME,
|
||||
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)"
|
||||
)
|
||||
public List<PostSummaryDto> latestReplyPosts(
|
||||
@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth
|
||||
) {
|
||||
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
|
||||
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
|
||||
// 只需要在请求的一开始统计一次
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
if (auth != null) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
||||
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/featured")
|
||||
@Operation(summary = "Featured posts", description = "List featured posts")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Featured posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> featuredPosts(
|
||||
@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth
|
||||
) {
|
||||
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
|
||||
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
|
||||
// 只需要在请求的一开始统计一次
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
return postService
|
||||
.listFeaturedPosts(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.DonationRequest;
|
||||
import com.openisle.dto.DonationResponse;
|
||||
import com.openisle.service.PointService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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
|
||||
@RequestMapping("/api/posts/{postId}/donations")
|
||||
@RequiredArgsConstructor
|
||||
public class PostDonationController {
|
||||
|
||||
private final PointService pointService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List donations", description = "Get recent donations for a post")
|
||||
@ApiResponse(responseCode = "200", description = "Donation summary")
|
||||
public DonationResponse list(@PathVariable Long postId) {
|
||||
return pointService.getPostDonations(postId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Donate", description = "Donate points to the post author")
|
||||
@ApiResponse(responseCode = "200", description = "Donation result")
|
||||
public DonationResponse donate(
|
||||
@PathVariable Long postId,
|
||||
@RequestBody DonationRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ package com.openisle.controller;
|
||||
import com.openisle.dto.PushPublicKeyDto;
|
||||
import com.openisle.dto.PushSubscriptionRequest;
|
||||
import com.openisle.service.PushSubscriptionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -12,19 +17,35 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/push")
|
||||
@RequiredArgsConstructor
|
||||
public class PushSubscriptionController {
|
||||
private final PushSubscriptionService pushSubscriptionService;
|
||||
@Value("${app.webpush.public-key}")
|
||||
private String publicKey;
|
||||
|
||||
@GetMapping("/public-key")
|
||||
public PushPublicKeyDto getPublicKey() {
|
||||
PushPublicKeyDto r = new PushPublicKeyDto();
|
||||
r.setKey(publicKey);
|
||||
return r;
|
||||
}
|
||||
private final PushSubscriptionService pushSubscriptionService;
|
||||
|
||||
@PostMapping("/subscribe")
|
||||
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
|
||||
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
|
||||
}
|
||||
@Value("${app.webpush.public-key}")
|
||||
private String publicKey;
|
||||
|
||||
@GetMapping("/public-key")
|
||||
@Operation(summary = "Get public key", description = "Retrieve web push public key")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Public key",
|
||||
content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class))
|
||||
)
|
||||
public PushPublicKeyDto getPublicKey() {
|
||||
PushPublicKeyDto r = new PushPublicKeyDto();
|
||||
r.setKey(publicKey);
|
||||
return r;
|
||||
}
|
||||
|
||||
@PostMapping("/subscribe")
|
||||
@Operation(summary = "Subscribe", description = "Subscribe to push notifications")
|
||||
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
|
||||
pushSubscriptionService.saveSubscription(
|
||||
auth.getName(),
|
||||
req.getEndpoint(),
|
||||
req.getP256dh(),
|
||||
req.getAuth()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import com.openisle.model.ReactionType;
|
||||
import com.openisle.service.LevelService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.service.ReactionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -17,44 +22,93 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
public class ReactionController {
|
||||
private final ReactionService reactionService;
|
||||
private final LevelService levelService;
|
||||
private final ReactionMapper reactionMapper;
|
||||
private final PointService pointService;
|
||||
|
||||
/**
|
||||
* Get all available reaction types.
|
||||
*/
|
||||
@GetMapping("/reaction-types")
|
||||
public ReactionType[] listReactionTypes() {
|
||||
return ReactionType.values();
|
||||
}
|
||||
private final ReactionService reactionService;
|
||||
private final LevelService levelService;
|
||||
private final ReactionMapper reactionMapper;
|
||||
private final PointService pointService;
|
||||
|
||||
@PostMapping("/posts/{postId}/reactions")
|
||||
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
pointService.awardForReactionOfPost(auth.getName(), postId);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
/**
|
||||
* Get all available reaction types.
|
||||
*/
|
||||
@GetMapping("/reaction-types")
|
||||
@Operation(summary = "List reaction types", description = "Get all available reaction types")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reaction types",
|
||||
content = @Content(schema = @Schema(implementation = ReactionType[].class))
|
||||
)
|
||||
public ReactionType[] listReactionTypes() {
|
||||
return ReactionType.values();
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}/reactions")
|
||||
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
pointService.awardForReactionOfComment(auth.getName(), commentId);
|
||||
return ResponseEntity.ok(dto);
|
||||
@PostMapping("/posts/{postId}/reactions")
|
||||
@Operation(summary = "React to post", description = "React to a post")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reaction result",
|
||||
content = @Content(schema = @Schema(implementation = ReactionDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<ReactionDto> reactToPost(
|
||||
@PathVariable Long postId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||
if (reaction == null) {
|
||||
pointService.deductForReactionOfPost(auth.getName(), postId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
pointService.awardForReactionOfPost(auth.getName(), postId);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}/reactions")
|
||||
@Operation(summary = "React to comment", description = "React to a comment")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reaction result",
|
||||
content = @Content(schema = @Schema(implementation = ReactionDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<ReactionDto> reactToComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||
if (reaction == null) {
|
||||
pointService.deductForReactionOfComment(auth.getName(), commentId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
pointService.awardForReactionOfComment(auth.getName(), commentId);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/messages/{messageId}/reactions")
|
||||
@Operation(summary = "React to message", description = "React to a message")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Reaction result",
|
||||
content = @Content(schema = @Schema(implementation = ReactionDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public ResponseEntity<ReactionDto> reactToMessage(
|
||||
@PathVariable Long messageId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
}
|
||||
|
||||
406
backend/src/main/java/com/openisle/controller/RssController.java
Normal file
@@ -0,0 +1,406 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.service.PostService;
|
||||
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
|
||||
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
|
||||
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
|
||||
import com.vladsch.flexmark.ext.tables.TablesExtension;
|
||||
import com.vladsch.flexmark.html.HtmlRenderer;
|
||||
import com.vladsch.flexmark.parser.Parser;
|
||||
import com.vladsch.flexmark.util.data.MutableDataSet;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.net.URI;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class RssController {
|
||||
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure)
|
||||
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
|
||||
private static final Pattern HTML_IMAGE = Pattern.compile(
|
||||
"<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"
|
||||
);
|
||||
|
||||
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||
|
||||
// flexmark:Markdown -> HTML
|
||||
private static final Parser MD_PARSER;
|
||||
private static final HtmlRenderer MD_RENDERER;
|
||||
|
||||
static {
|
||||
MutableDataSet opts = new MutableDataSet();
|
||||
opts.set(
|
||||
Parser.EXTENSIONS,
|
||||
Arrays.asList(
|
||||
TablesExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create()
|
||||
)
|
||||
);
|
||||
// 允许内联 HTML(下游再做 sanitize)
|
||||
opts.set(Parser.HTML_BLOCK_PARSER, true);
|
||||
MD_PARSER = Parser.builder(opts).build();
|
||||
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
|
||||
@Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "RSS XML",
|
||||
content = @Content(schema = @Schema(implementation = String.class))
|
||||
)
|
||||
public String feed() {
|
||||
// 建议 20;你现在是 10,这里保留你的 10
|
||||
List<Post> posts = postService.listLatestRssPosts(10);
|
||||
String base = trimTrailingSlash(websiteUrl);
|
||||
|
||||
StringBuilder sb = new StringBuilder(4096);
|
||||
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
|
||||
sb.append("<channel>");
|
||||
elem(sb, "title", cdata("OpenIsle RSS"));
|
||||
elem(sb, "link", base + "/");
|
||||
elem(sb, "description", cdata("Latest posts"));
|
||||
ZonedDateTime updated = posts
|
||||
.stream()
|
||||
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(ZonedDateTime.now());
|
||||
// channel lastBuildDate(GMT)
|
||||
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
|
||||
|
||||
for (Post p : posts) {
|
||||
String link = base + "/posts/" + p.getId();
|
||||
|
||||
// 1) Markdown -> HTML
|
||||
String html = renderMarkdown(p.getContent());
|
||||
|
||||
// 2) Sanitize(白名单增强)
|
||||
String safeHtml = sanitizeHtml(html);
|
||||
|
||||
// 3) 绝对化 href/src + 强制 rel/target
|
||||
String absHtml = absolutifyHtml(safeHtml, base);
|
||||
|
||||
// 4) 纯文本摘要(用于 <description>)
|
||||
String plain = textSummary(absHtml, 180);
|
||||
|
||||
// 5) enclosure(首图,已绝对化)
|
||||
String enclosure = firstImage(p.getContent());
|
||||
if (enclosure == null) {
|
||||
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
|
||||
enclosure = firstImage(absHtml);
|
||||
}
|
||||
if (enclosure != null) {
|
||||
enclosure = absolutifyUrl(enclosure, base);
|
||||
}
|
||||
|
||||
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
|
||||
List<Comment> topComments = commentService.getCommentsForPost(
|
||||
p.getId(),
|
||||
CommentSort.MOST_INTERACTIONS
|
||||
);
|
||||
topComments = topComments.subList(0, Math.min(10, topComments.size()));
|
||||
String footerHtml = buildFooterHtml(base, link, topComments);
|
||||
|
||||
sb.append("<item>");
|
||||
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
||||
elem(sb, "link", link);
|
||||
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
|
||||
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
|
||||
// 摘要
|
||||
elem(sb, "description", cdata(plain));
|
||||
// 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML)
|
||||
sb
|
||||
.append("<content:encoded><![CDATA[")
|
||||
.append(absHtml)
|
||||
.append(footerHtml)
|
||||
.append("]]></content:encoded>");
|
||||
// 首图 enclosure(图片类型)
|
||||
if (enclosure != null) {
|
||||
sb
|
||||
.append("<enclosure url=\"")
|
||||
.append(escapeXml(enclosure))
|
||||
.append("\" type=\"")
|
||||
.append(getMimeType(enclosure))
|
||||
.append("\" />");
|
||||
}
|
||||
sb.append("</item>");
|
||||
}
|
||||
|
||||
sb.append("</channel></rss>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/* ===================== Markdown → HTML ===================== */
|
||||
|
||||
private static String renderMarkdown(String md) {
|
||||
if (md == null || md.isEmpty()) return "";
|
||||
return MD_RENDERER.render(MD_PARSER.parse(md));
|
||||
}
|
||||
|
||||
/* ===================== Sanitize & 绝对化 ===================== */
|
||||
|
||||
private static String sanitizeHtml(String html) {
|
||||
if (html == null) return "";
|
||||
Safelist wl = Safelist.relaxed()
|
||||
.addTags(
|
||||
"pre",
|
||||
"code",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"picture",
|
||||
"source",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"blockquote"
|
||||
)
|
||||
.addAttributes("a", "href", "title", "target", "rel")
|
||||
.addAttributes("img", "src", "alt", "title", "width", "height")
|
||||
.addAttributes("source", "srcset", "type", "media")
|
||||
.addAttributes("code", "class")
|
||||
.addAttributes("pre", "class")
|
||||
.addProtocols("a", "href", "http", "https", "mailto")
|
||||
.addProtocols("img", "src", "http", "https", "data")
|
||||
.addProtocols("source", "srcset", "http", "https");
|
||||
// 清除所有 on* 事件、style(避免阅读器环境差异)
|
||||
return Jsoup.clean(html, wl);
|
||||
}
|
||||
|
||||
private static String absolutifyHtml(String html, String baseUrl) {
|
||||
if (html == null || html.isEmpty()) return "";
|
||||
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
|
||||
// a[href]
|
||||
for (Element a : doc.select("a[href]")) {
|
||||
String href = a.attr("href");
|
||||
String abs = absolutifyUrl(href, baseUrl);
|
||||
a.attr("href", abs);
|
||||
// 强制外链安全属性
|
||||
a.attr("rel", "noopener noreferrer nofollow");
|
||||
a.attr("target", "_blank");
|
||||
}
|
||||
// img[src]
|
||||
for (Element img : doc.select("img[src]")) {
|
||||
String src = img.attr("src");
|
||||
String abs = absolutifyUrl(src, baseUrl);
|
||||
img.attr("src", abs);
|
||||
}
|
||||
// source[srcset] (picture/webp)
|
||||
for (Element s : doc.select("source[srcset]")) {
|
||||
String srcset = s.attr("srcset");
|
||||
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
|
||||
}
|
||||
return doc.body().html();
|
||||
}
|
||||
|
||||
private static String absolutifyUrl(String url, String baseUrl) {
|
||||
if (url == null || url.isEmpty()) return url;
|
||||
String u = url.trim();
|
||||
if (u.startsWith("//")) {
|
||||
return "https:" + u;
|
||||
}
|
||||
if (u.startsWith("#")) {
|
||||
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link,但此处无上下文)
|
||||
return baseUrl + "/" + u;
|
||||
}
|
||||
try {
|
||||
URI base = URI.create(ensureTrailingSlash(baseUrl));
|
||||
URI abs = base.resolve(u);
|
||||
return abs.toString();
|
||||
} catch (Exception e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private static String absolutifySrcset(String srcset, String baseUrl) {
|
||||
if (srcset == null || srcset.isEmpty()) return srcset;
|
||||
String[] parts = srcset.split(",");
|
||||
List<String> out = new ArrayList<>(parts.length);
|
||||
for (String part : parts) {
|
||||
String p = part.trim();
|
||||
if (p.isEmpty()) continue;
|
||||
String[] seg = p.split("\\s+");
|
||||
String url = seg[0];
|
||||
String size = seg.length > 1 ? seg[1] : "";
|
||||
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
|
||||
}
|
||||
return String.join(", ", out);
|
||||
}
|
||||
|
||||
/* ===================== 摘要 & enclosure ===================== */
|
||||
|
||||
private static String textSummary(String html, int maxLen) {
|
||||
if (html == null) return "";
|
||||
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
||||
if (text.length() <= maxLen) return text;
|
||||
return text.substring(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
private String firstImage(String content) {
|
||||
if (content == null) return null;
|
||||
Matcher m = MD_IMAGE.matcher(content);
|
||||
if (m.find()) return m.group(1);
|
||||
m = HTML_IMAGE.matcher(content);
|
||||
if (m.find()) return m.group(1);
|
||||
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
|
||||
try {
|
||||
Document doc = Jsoup.parse(content);
|
||||
Element img = doc.selectFirst("img[src]");
|
||||
if (img != null) return img.attr("src");
|
||||
} catch (Exception ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getMimeType(String url) {
|
||||
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".avif")) return "image/avif";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
// 默认兜底
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
|
||||
|
||||
/**
|
||||
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML,
|
||||
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
|
||||
*/
|
||||
private static String buildFooterHtml(
|
||||
String baseUrl,
|
||||
String originalLink,
|
||||
List<Comment> topComments
|
||||
) {
|
||||
StringBuilder md = new StringBuilder(256);
|
||||
|
||||
// 分割线
|
||||
md.append("\n\n---\n\n");
|
||||
|
||||
// 原文链接(强调 + 可点击)
|
||||
md
|
||||
.append("**原文链接:** ")
|
||||
.append("[")
|
||||
.append(originalLink)
|
||||
.append("](")
|
||||
.append(originalLink)
|
||||
.append(")")
|
||||
.append("\n\n");
|
||||
|
||||
// 精选评论(仅当有评论时展示)
|
||||
if (topComments != null && !topComments.isEmpty()) {
|
||||
md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n");
|
||||
for (Comment c : topComments) {
|
||||
String author = usernameOf(c);
|
||||
String content = nullSafe(c.getContent()).replace("\r", "");
|
||||
// 使用引用样式展示,提升可读性
|
||||
md.append("> @").append(author).append(": ").append(content).append("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染为 HTML,并保持和正文一致的处理流程
|
||||
String html = renderMarkdown(md.toString());
|
||||
String safe = sanitizeHtml(html);
|
||||
return absolutifyHtml(safe, baseUrl);
|
||||
}
|
||||
|
||||
private static String usernameOf(Comment c) {
|
||||
if (c == null) return "匿名";
|
||||
try {
|
||||
Object authorObj = c.getAuthor();
|
||||
if (authorObj == null) return "匿名";
|
||||
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
|
||||
String username;
|
||||
try {
|
||||
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
|
||||
} catch (Exception e) {
|
||||
username = null;
|
||||
}
|
||||
if (username == null || username.isEmpty()) return "匿名";
|
||||
return username;
|
||||
} catch (Exception ignored) {
|
||||
return "匿名";
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 时间/字符串/XML ===================== */
|
||||
|
||||
private static String toRfc1123Gmt(ZonedDateTime zdt) {
|
||||
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
|
||||
}
|
||||
|
||||
private static String cdata(String s) {
|
||||
if (s == null) return "<![CDATA[]]>";
|
||||
// 防止出现 "]]>" 终止标记破坏 CDATA
|
||||
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
|
||||
}
|
||||
|
||||
private static void elem(StringBuilder sb, String name, String value) {
|
||||
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
|
||||
}
|
||||
|
||||
private static String escapeXml(String s) {
|
||||
if (s == null) return "";
|
||||
return s
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
if (s == null) return "";
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
|
||||
private static String ensureTrailingSlash(String s) {
|
||||
if (s == null || s.isEmpty()) return "/";
|
||||
return s.endsWith("/") ? s : s + "/";
|
||||
}
|
||||
|
||||
private static String nullSafe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -6,64 +6,120 @@ import com.openisle.dto.UserDto;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.mapper.UserMapper;
|
||||
import com.openisle.service.SearchService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
@RequiredArgsConstructor
|
||||
public class SearchController {
|
||||
private final SearchService searchService;
|
||||
private final UserMapper userMapper;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@GetMapping("/users")
|
||||
public List<UserDto> searchUsers(@RequestParam String keyword) {
|
||||
return searchService.searchUsers(keyword).stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
private final SearchService searchService;
|
||||
private final UserMapper userMapper;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@GetMapping("/posts")
|
||||
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
|
||||
return searchService.searchPosts(keyword).stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/users")
|
||||
@Operation(summary = "Search users", description = "Search users by keyword")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of users",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||
)
|
||||
public List<UserDto> searchUsers(@RequestParam String keyword) {
|
||||
return searchService
|
||||
.searchUsers(keyword)
|
||||
.stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/posts/content")
|
||||
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
|
||||
return searchService.searchPostsByContent(keyword).stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/posts")
|
||||
@Operation(summary = "Search posts", description = "Search posts by keyword")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
|
||||
return searchService
|
||||
.searchPosts(keyword)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/posts/title")
|
||||
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
|
||||
return searchService.searchPostsByTitle(keyword).stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/posts/content")
|
||||
@Operation(summary = "Search posts by content", description = "Search posts by content keyword")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
|
||||
return searchService
|
||||
.searchPostsByContent(keyword)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/global")
|
||||
public List<SearchResultDto> global(@RequestParam String keyword) {
|
||||
return searchService.globalSearch(keyword).stream()
|
||||
.map(r -> {
|
||||
SearchResultDto dto = new SearchResultDto();
|
||||
dto.setType(r.type());
|
||||
dto.setId(r.id());
|
||||
dto.setText(r.text());
|
||||
dto.setSubText(r.subText());
|
||||
dto.setExtra(r.extra());
|
||||
dto.setPostId(r.postId());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/posts/title")
|
||||
@Operation(summary = "Search posts by title", description = "Search posts by title keyword")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
|
||||
return searchService
|
||||
.searchPostsByTitle(keyword)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/global")
|
||||
@Operation(summary = "Global search", description = "Search users and posts globally")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Search results",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class))
|
||||
)
|
||||
)
|
||||
public List<SearchResultDto> global(@RequestParam String keyword) {
|
||||
return searchService
|
||||
.globalSearch(keyword)
|
||||
.stream()
|
||||
.map(r -> {
|
||||
SearchResultDto dto = new SearchResultDto();
|
||||
dto.setType(r.type());
|
||||
dto.setId(r.id());
|
||||
dto.setText(r.text());
|
||||
dto.setSubText(r.subText());
|
||||
dto.setExtra(r.extra());
|
||||
dto.setPostId(r.postId());
|
||||
dto.setHighlightedText(r.highlightedText());
|
||||
dto.setHighlightedSubText(r.highlightedSubText());
|
||||
dto.setHighlightedExtra(r.highlightedExtra());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package com.openisle.controller;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -11,8 +16,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Controller for dynamic sitemap generation.
|
||||
*/
|
||||
@@ -20,50 +23,47 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api")
|
||||
public class SitemapController {
|
||||
private final PostRepository postRepository;
|
||||
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
private final PostRepository postRepository;
|
||||
|
||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
public ResponseEntity<String> sitemap() {
|
||||
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
@Operation(summary = "Sitemap", description = "Generate sitemap xml")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Sitemap xml",
|
||||
content = @Content(schema = @Schema(implementation = String.class))
|
||||
)
|
||||
public ResponseEntity<String> sitemap() {
|
||||
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
|
||||
|
||||
List<String> staticRoutes = List.of(
|
||||
"/",
|
||||
"/about",
|
||||
"/activities",
|
||||
"/login",
|
||||
"/signup"
|
||||
);
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||
|
||||
for (String path : staticRoutes) {
|
||||
body.append(" <url><loc>")
|
||||
.append(websiteUrl)
|
||||
.append(path)
|
||||
.append("</loc></url>\n");
|
||||
}
|
||||
List<String> staticRoutes = List.of("/", "/about", "/activities", "/login", "/signup");
|
||||
|
||||
for (Post p : posts) {
|
||||
body.append(" <url>\n")
|
||||
.append(" <loc>")
|
||||
.append(websiteUrl)
|
||||
.append("/posts/")
|
||||
.append(p.getId())
|
||||
.append("</loc>\n")
|
||||
.append(" <lastmod>")
|
||||
.append(p.getCreatedAt().toLocalDate())
|
||||
.append("</lastmod>\n")
|
||||
.append(" </url>\n");
|
||||
}
|
||||
|
||||
body.append("</urlset>");
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(body.toString());
|
||||
for (String path : staticRoutes) {
|
||||
body.append(" <url><loc>").append(websiteUrl).append(path).append("</loc></url>\n");
|
||||
}
|
||||
|
||||
for (Post p : posts) {
|
||||
body
|
||||
.append(" <url>\n")
|
||||
.append(" <loc>")
|
||||
.append(websiteUrl)
|
||||
.append("/posts/")
|
||||
.append(p.getId())
|
||||
.append("</loc>\n")
|
||||
.append(" <lastmod>")
|
||||
.append(p.getCreatedAt().toLocalDate())
|
||||
.append("</lastmod>\n")
|
||||
.append(" </url>\n");
|
||||
}
|
||||
|
||||
body.append("</urlset>");
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_XML).body(body.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.UserVisitService;
|
||||
import com.openisle.service.StatService;
|
||||
import com.openisle.service.UserVisitService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -9,77 +17,111 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
@RequiredArgsConstructor
|
||||
public class StatController {
|
||||
private final UserVisitService userVisitService;
|
||||
private final StatService statService;
|
||||
|
||||
@GetMapping("/dau")
|
||||
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
long count = userVisitService.countDau(date);
|
||||
return Map.of("dau", count);
|
||||
}
|
||||
private final UserVisitService userVisitService;
|
||||
private final StatService statService;
|
||||
|
||||
@GetMapping("/dau-range")
|
||||
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = userVisitService.countDauRange(start, end);
|
||||
return data.entrySet().stream()
|
||||
.map(e -> Map.<String,Object>of(
|
||||
"date", e.getKey().toString(),
|
||||
"value", e.getValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@GetMapping("/dau")
|
||||
@Operation(summary = "Daily active users", description = "Get daily active user count")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "DAU count",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
public Map<String, Long> dau(
|
||||
@RequestParam(value = "date", required = false) @DateTimeFormat(
|
||||
iso = DateTimeFormat.ISO.DATE
|
||||
) LocalDate date
|
||||
) {
|
||||
long count = userVisitService.countDau(date);
|
||||
return Map.of("dau", count);
|
||||
}
|
||||
|
||||
@GetMapping("/new-users-range")
|
||||
public List<Map<String, Object>> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countNewUsersRange(start, end);
|
||||
return data.entrySet().stream()
|
||||
.map(e -> Map.<String,Object>of(
|
||||
"date", e.getKey().toString(),
|
||||
"value", e.getValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@GetMapping("/dau-range")
|
||||
@Operation(summary = "DAU range", description = "Get daily active users over range of days")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "DAU data",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||
)
|
||||
public List<Map<String, Object>> dauRange(
|
||||
@RequestParam(value = "days", defaultValue = "30") int days
|
||||
) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = userVisitService.countDauRange(start, end);
|
||||
return data
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/posts-range")
|
||||
public List<Map<String, Object>> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countPostsRange(start, end);
|
||||
return data.entrySet().stream()
|
||||
.map(e -> Map.<String,Object>of(
|
||||
"date", e.getKey().toString(),
|
||||
"value", e.getValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@GetMapping("/new-users-range")
|
||||
@Operation(summary = "New users range", description = "Get new users over range of days")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "New user data",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||
)
|
||||
public List<Map<String, Object>> newUsersRange(
|
||||
@RequestParam(value = "days", defaultValue = "30") int days
|
||||
) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countNewUsersRange(start, end);
|
||||
return data
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/comments-range")
|
||||
public List<Map<String, Object>> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countCommentsRange(start, end);
|
||||
return data.entrySet().stream()
|
||||
.map(e -> Map.<String,Object>of(
|
||||
"date", e.getKey().toString(),
|
||||
"value", e.getValue()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@GetMapping("/posts-range")
|
||||
@Operation(summary = "Posts range", description = "Get posts count over range of days")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Post data",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||
)
|
||||
public List<Map<String, Object>> postsRange(
|
||||
@RequestParam(value = "days", defaultValue = "30") int days
|
||||
) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countPostsRange(start, end);
|
||||
return data
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/comments-range")
|
||||
@Operation(summary = "Comments range", description = "Get comments count over range of days")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Comment data",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||
)
|
||||
public List<Map<String, Object>> commentsRange(
|
||||
@RequestParam(value = "days", defaultValue = "30") int days
|
||||
) {
|
||||
if (days < 1) days = 1;
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var data = statService.countCommentsRange(start, end);
|
||||
return data
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -10,35 +13,54 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/subscriptions")
|
||||
@RequiredArgsConstructor
|
||||
public class SubscriptionController {
|
||||
private final SubscriptionService subscriptionService;
|
||||
|
||||
@PostMapping("/posts/{postId}")
|
||||
public void subscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.subscribePost(auth.getName(), postId);
|
||||
}
|
||||
private final SubscriptionService subscriptionService;
|
||||
|
||||
@DeleteMapping("/posts/{postId}")
|
||||
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.unsubscribePost(auth.getName(), postId);
|
||||
}
|
||||
@PostMapping("/posts/{postId}")
|
||||
@Operation(summary = "Subscribe post", description = "Subscribe to a post")
|
||||
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void subscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.subscribePost(auth.getName(), postId);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{commentId}")
|
||||
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.subscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
@DeleteMapping("/posts/{postId}")
|
||||
@Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post")
|
||||
@ApiResponse(responseCode = "200", description = "Unsubscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
|
||||
subscriptionService.unsubscribePost(auth.getName(), postId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.unsubscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
@PostMapping("/comments/{commentId}")
|
||||
@Operation(summary = "Subscribe comment", description = "Subscribe to a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.subscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
|
||||
@PostMapping("/users/{username}")
|
||||
public void subscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.subscribeUser(auth.getName(), username);
|
||||
}
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
@Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Unsubscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||
subscriptionService.unsubscribeComment(auth.getName(), commentId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{username}")
|
||||
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.unsubscribeUser(auth.getName(), username);
|
||||
}
|
||||
@PostMapping("/users/{username}")
|
||||
@Operation(summary = "Subscribe user", description = "Subscribe to a user")
|
||||
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void subscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.subscribeUser(auth.getName(), username);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{username}")
|
||||
@Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user")
|
||||
@ApiResponse(responseCode = "200", description = "Unsubscribed")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
|
||||
subscriptionService.unsubscribeUser(auth.getName(), username);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,85 +11,156 @@ import com.openisle.model.Tag;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.TagService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tags")
|
||||
@RequiredArgsConstructor
|
||||
public class TagController {
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
private final UserRepository userRepository;
|
||||
private final PostMapper postMapper;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@PostMapping
|
||||
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
|
||||
boolean approved = true;
|
||||
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
|
||||
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
|
||||
if (user.getRole() != Role.ADMIN) {
|
||||
approved = false;
|
||||
}
|
||||
}
|
||||
Tag tag = tagService.createTag(
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon(),
|
||||
approved,
|
||||
auth != null ? auth.getName() : null);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
private final TagService tagService;
|
||||
private final PostService postService;
|
||||
private final UserRepository userRepository;
|
||||
private final PostMapper postMapper;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
|
||||
Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
@PostMapping
|
||||
@Operation(summary = "Create tag", description = "Create a new tag")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Created tag",
|
||||
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||
)
|
||||
@SecurityRequirement(name = "JWT")
|
||||
public TagDto create(
|
||||
@RequestBody TagRequest req,
|
||||
org.springframework.security.core.Authentication auth
|
||||
) {
|
||||
boolean approved = true;
|
||||
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
|
||||
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
|
||||
if (user.getRole() != Role.ADMIN) {
|
||||
approved = false;
|
||||
}
|
||||
}
|
||||
Tag tag = tagService.createTag(
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon(),
|
||||
approved,
|
||||
auth != null ? auth.getName() : null
|
||||
);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable Long id) {
|
||||
tagService.deleteTag(id);
|
||||
}
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update tag", description = "Update an existing tag")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated tag",
|
||||
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||
)
|
||||
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
|
||||
Tag tag = tagService.updateTag(
|
||||
id,
|
||||
req.getName(),
|
||||
req.getDescription(),
|
||||
req.getIcon(),
|
||||
req.getSmallIcon()
|
||||
);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
List<TagDto> dtos = tags.stream()
|
||||
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
return dtos.subList(0, limit);
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete tag", description = "Delete a tag by id")
|
||||
@ApiResponse(responseCode = "200", description = "Tag deleted")
|
||||
public void delete(@PathVariable Long id) {
|
||||
tagService.deleteTag(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public TagDto get(@PathVariable Long id) {
|
||||
Tag tag = tagService.getTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
@GetMapping
|
||||
@Operation(summary = "List tags", description = "List tags with optional keyword")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of tags",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||
)
|
||||
public List<TagDto> list(
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
if (postCntByTagIds == null) {
|
||||
postCntByTagIds = java.util.Collections.emptyMap();
|
||||
}
|
||||
Map<Long, Long> finalPostCntByTagIds = postCntByTagIds;
|
||||
List<TagDto> dtos = tags
|
||||
.stream()
|
||||
.map(t -> tagMapper.toDto(t, finalPostCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (page != null && pageSize != null && page >= 0 && pageSize > 0) {
|
||||
int fromIndex = page * pageSize;
|
||||
if (fromIndex >= dtos.size()) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
int toIndex = Math.min(fromIndex + pageSize, dtos.size());
|
||||
return new java.util.ArrayList<>(dtos.subList(fromIndex, toIndex));
|
||||
}
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
return new java.util.ArrayList<>(dtos.subList(0, limit));
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return postService.listPostsByTags(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get tag", description = "Get tag by id")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Tag detail",
|
||||
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||
)
|
||||
public TagDto get(@PathVariable Long id) {
|
||||
Tag tag = tagService.getTag(id);
|
||||
long count = postService.countPostsByTag(tag.getId());
|
||||
return tagMapper.toDto(tag, count);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/posts")
|
||||
@Operation(summary = "List posts by tag", description = "Get posts with specific tag")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> listPostsByTag(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize
|
||||
) {
|
||||
return postService
|
||||
.listPostsByTags(java.util.List.of(id), page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,99 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.ImageUploader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/upload")
|
||||
@RequiredArgsConstructor
|
||||
public class UploadController {
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
|
||||
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
String url;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"code", 0,
|
||||
"msg", "ok",
|
||||
"data", Map.of("url", url)
|
||||
));
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Upload file", description = "Upload image file")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Upload result",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
|
||||
if (
|
||||
checkImageType &&
|
||||
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
|
||||
@PostMapping("/url")
|
||||
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
|
||||
String link = body.get("url");
|
||||
if (link == null || link.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
|
||||
}
|
||||
try {
|
||||
URL u = URI.create(link).toURL();
|
||||
byte[] data = u.openStream().readAllBytes();
|
||||
if (data.length > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
String filename = link.substring(link.lastIndexOf('/') + 1);
|
||||
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
|
||||
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
String url = imageUploader.upload(data, filename).join();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"code", 0,
|
||||
"msg", "ok",
|
||||
"data", Map.of("url", url)
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
|
||||
@GetMapping("/presign")
|
||||
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
|
||||
return imageUploader.presignUpload(filename);
|
||||
String url;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
|
||||
}
|
||||
|
||||
@PostMapping("/url")
|
||||
@Operation(summary = "Upload from URL", description = "Upload image from remote URL")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Upload result",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
|
||||
String link = body.get("url");
|
||||
if (link == null || link.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
|
||||
}
|
||||
try {
|
||||
URL u = URI.create(link).toURL();
|
||||
byte[] data = u.openStream().readAllBytes();
|
||||
if (data.length > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||
}
|
||||
String filename = link.substring(link.lastIndexOf('/') + 1);
|
||||
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
|
||||
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||
}
|
||||
String url = imageUploader.upload(data, filename).join();
|
||||
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/presign")
|
||||
@Operation(summary = "Presign upload", description = "Get presigned upload URL")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Presigned URL",
|
||||
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||
)
|
||||
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
|
||||
return imageUploader.presignUpload(filename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import com.openisle.mapper.TagMapper;
|
||||
import com.openisle.mapper.UserMapper;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -13,193 +21,359 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final ImageUploader imageUploader;
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
private final ReactionService reactionService;
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
private final UserService userService;
|
||||
private final ImageUploader imageUploader;
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
private final ReactionService reactionService;
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
@Value("${app.upload.check-type:true}")
|
||||
private boolean checkImageType;
|
||||
|
||||
@Value("${app.user.posts-limit:10}")
|
||||
private int defaultPostsLimit;
|
||||
@Value("${app.upload.max-size:5242880}")
|
||||
private long maxUploadSize;
|
||||
|
||||
@Value("${app.user.replies-limit:50}")
|
||||
private int defaultRepliesLimit;
|
||||
@Value("${app.user.posts-limit:10}")
|
||||
private int defaultPostsLimit;
|
||||
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
@Value("${app.user.replies-limit:50}")
|
||||
private int defaultRepliesLimit;
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<UserDto> me(Authentication auth) {
|
||||
User user = userService.findByUsername(auth.getName()).orElseThrow();
|
||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
|
||||
@GetMapping("/me")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User detail",
|
||||
content = @Content(schema = @Schema(implementation = UserDto.class))
|
||||
)
|
||||
public ResponseEntity<UserDto> me(Authentication auth) {
|
||||
User user = userService.findByUsername(auth.getName()).orElseThrow();
|
||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
||||
}
|
||||
|
||||
@PostMapping("/me/avatar")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Upload avatar", description = "Upload avatar for current user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Upload result",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> uploadAvatar(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Authentication auth
|
||||
) {
|
||||
if (
|
||||
checkImageType &&
|
||||
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
|
||||
) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
|
||||
}
|
||||
|
||||
@PostMapping("/me/avatar")
|
||||
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
|
||||
Authentication auth) {
|
||||
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
|
||||
}
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
|
||||
}
|
||||
String url = null;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("url", url));
|
||||
}
|
||||
userService.updateAvatar(auth.getName(), url);
|
||||
return ResponseEntity.ok(Map.of("url", url));
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
||||
Authentication auth) {
|
||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(user.getUsername()),
|
||||
"user", userMapper.toDto(user, auth)
|
||||
));
|
||||
String url = null;
|
||||
try {
|
||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("url", url));
|
||||
}
|
||||
userService.updateAvatar(auth.getName(), url);
|
||||
return ResponseEntity.ok(Map.of("url", url));
|
||||
}
|
||||
|
||||
@PostMapping("/me/signin")
|
||||
public Map<String, Integer> signIn(Authentication auth) {
|
||||
int reward = levelService.awardForSignin(auth.getName());
|
||||
return Map.of("reward", reward);
|
||||
}
|
||||
@PutMapping("/me")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Update profile", description = "Update current user's profile")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Updated profile",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto, Authentication auth) {
|
||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"token",
|
||||
jwtService.generateToken(user.getUsername()),
|
||||
"user",
|
||||
userMapper.toDto(user, auth)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}")
|
||||
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
|
||||
Authentication auth) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
|
||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
||||
}
|
||||
// 这个方法似乎没有使用?
|
||||
@PostMapping("/me/signin")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Sign in reward",
|
||||
content = @Content(schema = @Schema(implementation = Map.class))
|
||||
)
|
||||
public Map<String, Integer> signIn(Authentication auth) {
|
||||
int reward = levelService.awardForSignin(auth.getName());
|
||||
return Map.of("reward", reward);
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/posts")
|
||||
public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultPostsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return postService.getRecentPostsByUser(user.getUsername(), l).stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}")
|
||||
@Operation(summary = "Get user", description = "Get user by identifier")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User detail",
|
||||
content = @Content(schema = @Schema(implementation = UserDto.class))
|
||||
)
|
||||
public ResponseEntity<UserDto> getUser(
|
||||
@PathVariable("identifier") String identifier,
|
||||
Authentication auth
|
||||
) {
|
||||
User user = userService
|
||||
.findByIdentifier(identifier)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/replies")
|
||||
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultRepliesLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return commentService.getRecentCommentsByUser(user.getUsername(), l).stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/posts")
|
||||
@Operation(summary = "User posts", description = "Get recent posts by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User posts",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||
)
|
||||
public java.util.List<PostMetaDto> userPosts(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : defaultPostsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return postService
|
||||
.getRecentPostsByUser(user.getUsername(), l)
|
||||
.stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-posts")
|
||||
public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
|
||||
return postService.getPostsByIds(ids).stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/subscribed-posts")
|
||||
@Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Subscribed posts",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||
)
|
||||
public java.util.List<PostMetaDto> subscribedPosts(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : defaultPostsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService
|
||||
.getSubscribedPosts(user.getUsername())
|
||||
.stream()
|
||||
.limit(l)
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-replies")
|
||||
public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
|
||||
return commentService.getCommentsByIds(ids).stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/replies")
|
||||
@Operation(summary = "User replies", description = "Get recent replies by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User replies",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
|
||||
)
|
||||
)
|
||||
public java.util.List<CommentInfoDto> userReplies(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : defaultRepliesLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return commentService
|
||||
.getRecentCommentsByUser(user.getUsername(), l)
|
||||
.stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/hot-tags")
|
||||
public java.util.List<TagDto> hotTags(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService.getTagsByUser(user.getUsername()).stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.limit(l)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/hot-posts")
|
||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Hot posts",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||
)
|
||||
public java.util.List<PostMetaDto> hotPosts(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
|
||||
return postService
|
||||
.getPostsByIds(ids)
|
||||
.stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/tags")
|
||||
public java.util.List<TagDto> userTags(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultTagsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/hot-replies")
|
||||
@Operation(summary = "User hot replies", description = "Get most reacted replies by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Hot replies",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
|
||||
)
|
||||
)
|
||||
public java.util.List<CommentInfoDto> hotReplies(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
|
||||
return commentService
|
||||
.getCommentsByIds(ids)
|
||||
.stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/following")
|
||||
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/hot-tags")
|
||||
@Operation(summary = "User hot tags", description = "Get tags frequently used by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Hot tags",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||
)
|
||||
public java.util.List<TagDto> hotTags(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : 10;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService
|
||||
.getTagsByUser(user.getUsername())
|
||||
.stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.limit(l)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/followers")
|
||||
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService.getSubscribers(user.getUsername()).stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/tags")
|
||||
@Operation(summary = "User tags", description = "Get recent tags used by user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User tags",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||
)
|
||||
public java.util.List<TagDto> userTags(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
int l = limit != null ? limit : defaultTagsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return tagService
|
||||
.getRecentTagsByUser(user.getUsername(), l)
|
||||
.stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
public java.util.List<UserDto> admins() {
|
||||
return userService.getAdmins().stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
@GetMapping("/{identifier}/following")
|
||||
@Operation(summary = "Following users", description = "Get users that this user is following")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Following list",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||
)
|
||||
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService
|
||||
.getSubscribedUsers(user.getUsername())
|
||||
.stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/all")
|
||||
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
||||
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
|
||||
Authentication auth) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
|
||||
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
|
||||
java.util.List<PostMetaDto> posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
java.util.List<CommentInfoDto> replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
UserAggregateDto dto = new UserAggregateDto();
|
||||
dto.setUser(userMapper.toDto(user, auth));
|
||||
dto.setPosts(posts);
|
||||
dto.setReplies(replies);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
@GetMapping("/{identifier}/followers")
|
||||
@Operation(summary = "Followers", description = "Get followers of this user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Followers list",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||
)
|
||||
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService
|
||||
.getSubscribers(user.getUsername())
|
||||
.stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
@Operation(summary = "Admin users", description = "List administrator users")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Admin users",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||
)
|
||||
public java.util.List<UserDto> admins() {
|
||||
return userService
|
||||
.getAdmins()
|
||||
.stream()
|
||||
.map(userMapper::toDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/all")
|
||||
@Operation(summary = "User aggregate", description = "Get aggregate information for user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User aggregate",
|
||||
content = @Content(schema = @Schema(implementation = UserAggregateDto.class))
|
||||
)
|
||||
public ResponseEntity<UserAggregateDto> userAggregate(
|
||||
@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
||||
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
|
||||
Authentication auth
|
||||
) {
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
|
||||
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
|
||||
java.util.List<PostMetaDto> posts = postService
|
||||
.getRecentPostsByUser(user.getUsername(), pLimit)
|
||||
.stream()
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
java.util.List<CommentInfoDto> replies = commentService
|
||||
.getRecentCommentsByUser(user.getUsername(), rLimit)
|
||||
.stream()
|
||||
.map(userMapper::toCommentInfoDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
UserAggregateDto dto = new UserAggregateDto();
|
||||
dto.setUser(userMapper.toDto(user, auth));
|
||||
dto.setPosts(posts);
|
||||
dto.setReplies(replies);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import com.openisle.model.ActivityType;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing an activity without participant details.
|
||||
*/
|
||||
@Data
|
||||
public class ActivityDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String icon;
|
||||
private String content;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private ActivityType type;
|
||||
private boolean ended;
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String icon;
|
||||
private String content;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private ActivityType type;
|
||||
private boolean ended;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import com.openisle.model.MedalType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing a post or comment author.
|
||||
*/
|
||||
@Data
|
||||
public class AuthorDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private MedalType displayMedal;
|
||||
}
|
||||
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private MedalType displayMedal;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import lombok.Data;
|
||||
*/
|
||||
@Data
|
||||
public class CategoryDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private Long count;
|
||||
}
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
private Long count;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import lombok.Data;
|
||||
/** Request body for creating or updating a category. */
|
||||
@Data
|
||||
public class CategoryRequest {
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String smallIcon;
|
||||
}
|
||||
|
||||
18
backend/src/main/java/com/openisle/dto/ChannelDto.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ChannelDto {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String avatar;
|
||||
private MessageDto lastMessage;
|
||||
private long memberCount;
|
||||
private boolean joined;
|
||||
private long unreadCount;
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing a comment and its nested replies.
|
||||
*/
|
||||
@Data
|
||||
public class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime pinnedAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
private int reward;
|
||||
private int pointReward;
|
||||
}
|
||||
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime pinnedAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
private int reward;
|
||||
private int pointReward;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
/** DTO for comment information in user profiles. */
|
||||
@Data
|
||||
public class CommentInfoDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private PostMetaDto post;
|
||||
private ParentCommentDto parentComment;
|
||||
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private PostMetaDto post;
|
||||
private ParentCommentDto parentComment;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class CommentMedalDto extends MedalDto {
|
||||
private long currentCommentCount;
|
||||
private long targetCommentCount;
|
||||
|
||||
private long currentCommentCount;
|
||||
private long targetCommentCount;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.Data;
|
||||
/** Request body for creating or replying to a comment. */
|
||||
@Data
|
||||
public class CommentRequest {
|
||||
private String content;
|
||||
private String captcha;
|
||||
|
||||
private String content;
|
||||
private String captcha;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import lombok.Data;
|
||||
/** DTO for site configuration. */
|
||||
@Data
|
||||
public class ConfigDto {
|
||||
private PublishMode publishMode;
|
||||
private PasswordStrength passwordStrength;
|
||||
private Integer aiFormatLimit;
|
||||
private RegisterMode registerMode;
|
||||
|
||||
private PublishMode publishMode;
|
||||
private PasswordStrength passwordStrength;
|
||||
private Integer aiFormatLimit;
|
||||
private RegisterMode registerMode;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ContributorMedalDto extends MedalDto {
|
||||
private long currentContributionLines;
|
||||
private long targetContributionLines;
|
||||
}
|
||||
|
||||
private long currentContributionLines;
|
||||
private long targetContributionLines;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
@Data
|
||||
public class ConversationDetailDto {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private boolean channel;
|
||||
private String avatar;
|
||||
private List<UserSummaryDto> participants;
|
||||
private Page<MessageDto> messages;
|
||||
}
|
||||
20
backend/src/main/java/com/openisle/dto/ConversationDto.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ConversationDto {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private boolean channel;
|
||||
private String avatar;
|
||||
private MessageDto lastMessage;
|
||||
private List<UserSummaryDto> participants;
|
||||
private LocalDateTime createdAt;
|
||||
private long unreadCount;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateConversationRequest {
|
||||
|
||||
private Long recipientId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class CreateConversationResponse {
|
||||
|
||||
private Long conversationId;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import lombok.Data;
|
||||
/** Request for Discord OAuth login. */
|
||||
@Data
|
||||
public class DiscordLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||