mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Compare commits
733
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123a44aed0 | ||
|
|
9ec613eddc | ||
|
|
8d9a79dee4 | ||
|
|
935c49487e | ||
|
|
54a438c762 | ||
|
|
7ab3bb81dd | ||
|
|
2054c5bdc4 | ||
|
|
110a9c2e0c | ||
|
|
c5c043052c | ||
|
|
c5f6362e67 | ||
|
|
cd0d1f3ab8 | ||
|
|
d47534ad8b | ||
|
|
315f8a55cc | ||
|
|
5a658c44f8 | ||
|
|
5ae7afaf29 | ||
|
|
ae6d165e98 | ||
|
|
e5aa1ccea7 | ||
|
|
ee553f8b67 | ||
|
|
a38bd0b4a6 | ||
|
|
b58cca4788 | ||
|
|
6149d0472f | ||
|
|
a0b3f022fe | ||
|
|
69001f2745 | ||
|
|
ec6e1cdfd1 | ||
|
|
7568275126 | ||
|
|
67a6a58e57 | ||
|
|
8e5c379853 | ||
|
|
2dffe8f6a8 | ||
|
|
c802f67a5a | ||
|
|
1916261795 | ||
|
|
325016adc8 | ||
|
|
09fd380c3a | ||
|
|
e6292747bd | ||
|
|
a96b2cfad1 | ||
|
|
a0a1bde88c | ||
|
|
9251a5d5fd | ||
|
|
e3d2c68592 | ||
|
|
4d6970c6cc | ||
|
|
d4f78d6448 | ||
|
|
efec3308df | ||
|
|
0441907b00 | ||
|
|
08ee3878c1 | ||
|
|
c7e686588e | ||
|
|
495046c978 | ||
|
|
ffaa1282cc | ||
|
|
15dda9ab6e | ||
|
|
814068a9e6 | ||
|
|
39cdfdfb00 | ||
|
|
4b730099cd | ||
|
|
974972b490 | ||
|
|
c9b4678148 | ||
|
|
d20bc91465 | ||
|
|
bc8d1ab9e3 | ||
|
|
a5e9a0e805 | ||
|
|
1897b3198e | ||
|
|
6b9c7d81de | ||
|
|
e9b83d3c93 | ||
|
|
0f0e94c9b1 | ||
|
|
ba0f30cde1 | ||
|
|
9f3673d812 | ||
|
|
ef9a6b554c | ||
|
|
dbfdc6762e | ||
|
|
2e86d79150 | ||
|
|
5352f98c13 | ||
|
|
e32b4232c5 | ||
|
|
0731f38702 | ||
|
|
7ebcdda26f | ||
|
|
f32f7e56a2 | ||
|
|
330639d975 | ||
|
|
0fa5468483 | ||
|
|
bfe4aa91ce | ||
|
|
5d85bc98d2 | ||
|
|
8f90f764b6 | ||
|
|
49d12f7825 | ||
|
|
b60c9ab17c | ||
|
|
576b2fc5d0 | ||
|
|
35a368ae04 | ||
|
|
776482c5d6 | ||
|
|
92f182cd09 | ||
|
|
0428a2b07d | ||
|
|
9f193c7e7c | ||
|
|
4e0ca6a214 | ||
|
|
45842d917a | ||
|
|
8719c63424 | ||
|
|
d71db7d58d | ||
|
|
02b5e670cc | ||
|
|
41f53d7225 | ||
|
|
b71179cea9 | ||
|
|
41a64bf64c | ||
|
|
afacc84da3 | ||
|
|
3bbe460beb | ||
|
|
131bfcf063 | ||
|
|
283d762141 | ||
|
|
bd9737cc6e | ||
|
|
6d381dba74 | ||
|
|
7e6856a5e0 | ||
|
|
c96181bd08 | ||
|
|
7a934fee32 | ||
|
|
5bec0e083b | ||
|
|
a97dab1716 | ||
|
|
5ff7f4bc69 | ||
|
|
94f643c587 | ||
|
|
c98ea57a94 | ||
|
|
a58ca7f156 | ||
|
|
c08f64ae75 | ||
|
|
48352563ef | ||
|
|
f87d8f43c8 | ||
|
|
568e3ca66b | ||
|
|
a9acc31a78 | ||
|
|
2866fc45b1 | ||
|
|
dcb68698e5 | ||
|
|
06f9751c84 | ||
|
|
23975442d8 | ||
|
|
577a13b271 | ||
|
|
05bb98dada | ||
|
|
4abe09dbc3 | ||
|
|
2bedeef26c | ||
|
|
8efcc394cc | ||
|
|
8fbd2c1ede | ||
|
|
19de14ffa6 | ||
|
|
2876f57ea4 | ||
|
|
e9ed1292f0 | ||
|
|
62ece2f289 | ||
|
|
0bd41a9e67 | ||
|
|
e8ac9e848c | ||
|
|
9698dde9c2 | ||
|
|
39c3366e0a | ||
|
|
6432a66732 | ||
|
|
1aa448796b | ||
|
|
db8fff0431 | ||
|
|
28113a4d52 | ||
|
|
d843dfa1c7 | ||
|
|
9e817db278 | ||
|
|
0dfe74d1a3 | ||
|
|
eb9399e14f | ||
|
|
0c9eafbbb9 | ||
|
|
b412dceb9e | ||
|
|
1de4201b52 | ||
|
|
bcdaf3f3c9 | ||
|
|
565800d8cf | ||
|
|
966779bcd7 | ||
|
|
3f06baffed | ||
|
|
6de3eee4e0 | ||
|
|
e227eb1768 | ||
|
|
b6875a3c37 | ||
|
|
c521d2ef36 | ||
|
|
4d457b2215 | ||
|
|
95aebe1ceb | ||
|
|
f5af411339 | ||
|
|
d1fd5b6694 | ||
|
|
ab1f60120a | ||
|
|
220de51aad | ||
|
|
646a0ec2fc | ||
|
|
6f1c27786d | ||
|
|
093788acea | ||
|
|
4bab3c2cb0 | ||
|
|
b82f9e8c1c | ||
|
|
89f8fe141e | ||
|
|
ac19f8480f | ||
|
|
d397a0d985 | ||
|
|
eb27b5bfed | ||
|
|
08674ab95f | ||
|
|
fbdc3f2dc5 | ||
|
|
19d8a2964f | ||
|
|
a496932138 | ||
|
|
a6da0bfd41 | ||
|
|
ced2b8d269 | ||
|
|
f2b246089d | ||
|
|
0aa63ed6e6 | ||
|
|
c8ebd1e9f6 | ||
|
|
68d962f2f8 | ||
|
|
1a6aba6105 | ||
|
|
b2785dd46f | ||
|
|
dc73f09c55 | ||
|
|
b4d984cb4e | ||
|
|
4d19cd2f5a | ||
|
|
d9d0c839ed | ||
|
|
ca51c2f8c5 | ||
|
|
88b46c1b72 | ||
|
|
383620c899 | ||
|
|
f876162456 | ||
|
|
362a559236 | ||
|
|
8ae1a43581 | ||
|
|
ee849f0854 | ||
|
|
5c9a27cc16 | ||
|
|
1b68854b84 | ||
|
|
b73585ebd2 | ||
|
|
28a6228556 | ||
|
|
dfce2ca094 | ||
|
|
326bdf9a17 | ||
|
|
cbe4e5ebe0 | ||
|
|
2d666e375f | ||
|
|
ff40c41549 | ||
|
|
7619670521 | ||
|
|
85ba942f35 | ||
|
|
88f3173f8f | ||
|
|
fd87dfb25a | ||
|
|
ad7f725e4c | ||
|
|
2ef8810376 | ||
|
|
f0e6ed9985 | ||
|
|
809879997c | ||
|
|
b153b7ac18 | ||
|
|
308ccf71ae | ||
|
|
c8f6d9da72 | ||
|
|
3f2c3e44ba | ||
|
|
b5e2a9e591 | ||
|
|
cdabe91092 | ||
|
|
69a59993e3 | ||
|
|
b6defc671e | ||
|
|
8cbea57a8a | ||
|
|
7845e1ff49 | ||
|
|
2b76b64e85 | ||
|
|
dd3165a082 | ||
|
|
407d12760b | ||
|
|
f8b244c3a9 | ||
|
|
dfc715aad8 | ||
|
|
4bc5ba53e6 | ||
|
|
6b4fd93e5f | ||
|
|
0c5956f828 | ||
|
|
122d17ab57 | ||
|
|
4fb14e480a | ||
|
|
ea4ac6ff41 | ||
|
|
bb5bcdf430 | ||
|
|
aad9afa9bb | ||
|
|
a0e6e12d85 | ||
|
|
5d680ff93f | ||
|
|
866b0e6ba4 | ||
|
|
5a49d20c9b | ||
|
|
7641d5993a | ||
|
|
07d66137cb | ||
|
|
f918dcd9c2 | ||
|
|
ed4e312cb1 | ||
|
|
0b539f868e | ||
|
|
8899832de9 | ||
|
|
d844aa323a | ||
|
|
ef640b0b5c | ||
|
|
4d43c6a3ea | ||
|
|
407c848145 | ||
|
|
d58260a0c0 | ||
|
|
16bcaf963d | ||
|
|
551dc51c52 | ||
|
|
0729061438 | ||
|
|
636d867544 | ||
|
|
cf9e5dc4f4 | ||
|
|
2c0acb1103 | ||
|
|
425bc202c2 | ||
|
|
3b24dca935 | ||
|
|
98db5f4984 | ||
|
|
2b56168ad0 | ||
|
|
b9255815e1 | ||
|
|
93e2f0f5f9 | ||
|
|
437c026da9 | ||
|
|
316079045f | ||
|
|
5ae9f8b288 | ||
|
|
bef65e1baa | ||
|
|
87a3cd8a9c | ||
|
|
be7b742891 | ||
|
|
1f98797a60 | ||
|
|
8505e32757 | ||
|
|
91297d1ccd | ||
|
|
0ea213edfd | ||
|
|
292f5d7e48 | ||
|
|
ef871f3b5c | ||
|
|
6b17da59f8 | ||
|
|
86d580edd8 | ||
|
|
cdbe37de7d | ||
|
|
f46974ddd9 | ||
|
|
a14d4243d9 | ||
|
|
90588ebae8 | ||
|
|
00bc02f49c | ||
|
|
7e9ce2e691 | ||
|
|
81ec0f0bd9 | ||
|
|
37f9716c4f | ||
|
|
4540cd9338 | ||
|
|
3c39d2f2f3 | ||
|
|
1e01ffc3d9 | ||
|
|
f4b1c43b55 | ||
|
|
02254938b4 | ||
|
|
054ff0cab2 | ||
|
|
4f898a0264 | ||
|
|
70d90c2c01 | ||
|
|
964143b30d | ||
|
|
ce9f59e7d6 | ||
|
|
1f958fc42a | ||
|
|
e6e53296a8 | ||
|
|
f533de8abd | ||
|
|
adc6427aab | ||
|
|
d034b78b48 | ||
|
|
6deda906de | ||
|
|
a063105de8 | ||
|
|
02b4215742 | ||
|
|
56b6e2113f | ||
|
|
4fcc78eb94 | ||
|
|
b28a4d7673 | ||
|
|
27383e4ff5 | ||
|
|
bf16f8916d | ||
|
|
75a62ac6aa | ||
|
|
bbc4aaa020 | ||
|
|
89e5d02fe2 | ||
|
|
a93973af06 | ||
|
|
15a9527866 | ||
|
|
9147d35c8c | ||
|
|
6a065797f5 | ||
|
|
dd08775c85 | ||
|
|
0cbe635ed8 | ||
|
|
e923f13e83 | ||
|
|
889da744ef | ||
|
|
74df1d1ce2 | ||
|
|
54e09b68c7 | ||
|
|
5762834744 | ||
|
|
51354593d0 | ||
|
|
8751425e2b | ||
|
|
921f05fa19 | ||
|
|
5754c5449d | ||
|
|
2f535e3510 | ||
|
|
c25e447530 | ||
|
|
13359b736c | ||
|
|
500f20d91c | ||
|
|
d4de42fb46 | ||
|
|
3f0537a727 | ||
|
|
d1cdb9e462 | ||
|
|
6c029b9be7 | ||
|
|
df12a0c701 | ||
|
|
0be5685d9f | ||
|
|
3efca8af37 | ||
|
|
82b2f808ac | ||
|
|
f417374f3e | ||
|
|
237534c63e | ||
|
|
b525124fdf | ||
|
|
5217c95451 | ||
|
|
ea973cac90 | ||
|
|
1bedda0e66 | ||
|
|
c1087fa51d | ||
|
|
cfddf44e7e | ||
|
|
085b32e921 | ||
|
|
2099c33aad | ||
|
|
b809497aba | ||
|
|
a6fe8c0e8d | ||
|
|
629cf7ce45 | ||
|
|
fad6c05d41 | ||
|
|
77d59d3453 | ||
|
|
931d6b281a | ||
|
|
d929f0dbdc | ||
|
|
6d1b47903e | ||
|
|
8420ccf190 | ||
|
|
1e38e1918b | ||
|
|
52e78152a1 | ||
|
|
dcb07c60f2 | ||
|
|
33c9f78cf0 | ||
|
|
37b1033697 | ||
|
|
0aa4ebc635 | ||
|
|
30d4107670 | ||
|
|
dcf235db78 | ||
|
|
1dfcd7ae3c | ||
|
|
2b28244099 | ||
|
|
f71071f905 | ||
|
|
34c9c398ca | ||
|
|
f099777dd3 | ||
|
|
c95be82cb9 | ||
|
|
00ec039095 | ||
|
|
4d087355b4 | ||
|
|
d57fabcbb4 | ||
|
|
3a32522e2b | ||
|
|
22f8850bf7 | ||
|
|
eeb709c6aa | ||
|
|
a6bbf2971d | ||
|
|
cd48bb4210 | ||
|
|
4500dd0128 | ||
|
|
2b6d8222ad | ||
|
|
1b47ab7e83 | ||
|
|
bd804e0a38 | ||
|
|
8e8af2032a | ||
|
|
e09062eb39 | ||
|
|
8c00492e44 | ||
|
|
0237d4c7b1 | ||
|
|
fe3a3b4e86 | ||
|
|
65ec721a1c | ||
|
|
4eb76ad6ea | ||
|
|
1f8687155a | ||
|
|
8591d4e592 | ||
|
|
dedf4e7bae | ||
|
|
1e4aa88487 | ||
|
|
0dd745b7a7 | ||
|
|
6b2402f212 | ||
|
|
24a487eeea | ||
|
|
4f574189c7 | ||
|
|
b559a74cea | ||
|
|
83dcdbe59d | ||
|
|
d64f7c2911 | ||
|
|
53e64bce37 | ||
|
|
00a781357b | ||
|
|
d7f87ed0ad | ||
|
|
bda9b1d463 | ||
|
|
e0a106ec51 | ||
|
|
016361bd68 | ||
|
|
f2d3c39425 | ||
|
|
e45d2d6fe7 | ||
|
|
4ddf84fa22 | ||
|
|
326a1883e8 | ||
|
|
9ff36d4498 | ||
|
|
98ef07ad32 | ||
|
|
802b0d456a | ||
|
|
6767f0e2e6 | ||
|
|
d72a05c965 | ||
|
|
ce3be1db8e | ||
|
|
e84ef04f15 | ||
|
|
adee3a2177 | ||
|
|
526e0aab9a | ||
|
|
2bbbbe764a | ||
|
|
ef5e921c08 | ||
|
|
a2fa59285a | ||
|
|
494942e87e | ||
|
|
aa2b76673f | ||
|
|
099880dd1c | ||
|
|
d906a103af | ||
|
|
4ed49d2d13 | ||
|
|
7b5ae4696f | ||
|
|
54ea301152 | ||
|
|
69c27232ac | ||
|
|
f36dcb3f5c | ||
|
|
63d088d62c | ||
|
|
a461f78224 | ||
|
|
c306432708 | ||
|
|
97f778cc90 | ||
|
|
88695ac6ad | ||
|
|
3b04329b04 | ||
|
|
bd4acf8121 | ||
|
|
b9a9b1efe6 | ||
|
|
c050f58ee9 | ||
|
|
d47375ae41 | ||
|
|
0602c6144d | ||
|
|
c84f0658fb | ||
|
|
a2902bd23a | ||
|
|
69a9b90711 | ||
|
|
b96402e11d | ||
|
|
0bc6f6a719 | ||
|
|
ebd65c8246 | ||
|
|
234bf708b3 | ||
|
|
6d5e741d94 | ||
|
|
f9bfe8f510 | ||
|
|
408c866dda | ||
|
|
bac41a46f8 | ||
|
|
8638c26db1 | ||
|
|
319ba13df1 | ||
|
|
0d1f05d307 | ||
|
|
e6608d18ce | ||
|
|
7afd8415cd | ||
|
|
845f2f0ff8 | ||
|
|
11d7792c0f | ||
|
|
6883ec34c6 | ||
|
|
4c45e7ff27 | ||
|
|
5621daea98 | ||
|
|
71d168dfea | ||
|
|
2d4365189d | ||
|
|
0c28a46e60 | ||
|
|
a4c7dae99c | ||
|
|
bfda0a226c | ||
|
|
c9bbb105a3 | ||
|
|
b415dc8912 | ||
|
|
05b74d71a3 | ||
|
|
10c0c691a7 | ||
|
|
0bc3786fc6 | ||
|
|
8e041ccb1b | ||
|
|
a82e9b7b9d | ||
|
|
7bab8cc636 | ||
|
|
7659b63d88 | ||
|
|
33ebc5723f | ||
|
|
86d9efd785 | ||
|
|
57ae7f03a6 | ||
|
|
6ee3983846 | ||
|
|
f05ed5cdda | ||
|
|
187dfa5344 | ||
|
|
918c8a9d33 | ||
|
|
ccfe35ea85 | ||
|
|
4a118c166a | ||
|
|
5f3823cb6d | ||
|
|
19ef52da9b | ||
|
|
39c3339a8d | ||
|
|
9f52629dcb | ||
|
|
1cddd6e2d5 | ||
|
|
ed10e6ff47 | ||
|
|
48574fb50a | ||
|
|
ee7fe3087d | ||
|
|
270ac3d941 | ||
|
|
2f76988076 | ||
|
|
b275481742 | ||
|
|
f3f1ceb595 | ||
|
|
bcc764142b | ||
|
|
9b0d9f8296 | ||
|
|
63ba7646c1 | ||
|
|
1a7734185f | ||
|
|
8541059d23 | ||
|
|
61edc81333 | ||
|
|
8998687adb | ||
|
|
85b0294da9 | ||
|
|
a8ecb4ae3b | ||
|
|
a87ff74b56 | ||
|
|
3c5682988d | ||
|
|
1d32154498 | ||
|
|
f4290ddb8c | ||
|
|
1e54b0ba42 | ||
|
|
27b9cf4c64 | ||
|
|
87f29e52a1 | ||
|
|
16a0f9646d | ||
|
|
032364e48e | ||
|
|
bc5d142920 | ||
|
|
9b3425d801 | ||
|
|
5842127c2b | ||
|
|
193eba3c58 | ||
|
|
338e50e255 | ||
|
|
65500d7a53 | ||
|
|
0833cd5443 | ||
|
|
e8c844ead7 | ||
|
|
1d5f2dc5f0 | ||
|
|
9a36154564 | ||
|
|
5f8ae74ea5 | ||
|
|
da8e7c1b01 | ||
|
|
f513b98f15 | ||
|
|
80cef6dde7 | ||
|
|
2d504f281e | ||
|
|
fc34c98f0a | ||
|
|
1cc020efdd | ||
|
|
bb33e99e33 | ||
|
|
6c70b34ae6 | ||
|
|
960d9f6215 | ||
|
|
085f08ec3b | ||
|
|
2a8722f56e | ||
|
|
4372b9bbc2 | ||
|
|
db81319dc4 | ||
|
|
644f7a0413 | ||
|
|
b93ff19ebe | ||
|
|
4c007cb47d | ||
|
|
c9e9a45288 | ||
|
|
d44a5491f5 | ||
|
|
289cf5b974 | ||
|
|
e207622a70 | ||
|
|
38d9d9fda5 | ||
|
|
996895e775 | ||
|
|
0675e53f2a | ||
|
|
e492cec7d7 | ||
|
|
92fe1e7554 | ||
|
|
7b080600f6 | ||
|
|
71f9667bc0 | ||
|
|
2bf548f8b9 | ||
|
|
15fd4545aa | ||
|
|
7f6c4ad6da | ||
|
|
2be12b4ea4 | ||
|
|
baebdd9870 | ||
|
|
d1648bb709 | ||
|
|
a4a394a18d | ||
|
|
951fdc902a | ||
|
|
18ffa06d70 | ||
|
|
d28f152d78 | ||
|
|
1e3e9ea84a | ||
|
|
5b42e59d65 | ||
|
|
1882467554 | ||
|
|
f1cd48e9d0 | ||
|
|
d1fd84617b | ||
|
|
03fcf82b26 | ||
|
|
f75e1bf3df | ||
|
|
554a6f59bd | ||
|
|
68d41cf028 | ||
|
|
5defeca590 | ||
|
|
4d94418124 | ||
|
|
6c258f056a | ||
|
|
87af1f1319 | ||
|
|
3f9badd4b9 | ||
|
|
06de770c4d | ||
|
|
b5948441ac | ||
|
|
acbab69f12 | ||
|
|
1f8ac4953e | ||
|
|
1d981ec159 | ||
|
|
b0f675f697 | ||
|
|
6d44f807a7 | ||
|
|
9602e28429 | ||
|
|
440f324a3d | ||
|
|
5e7a4e1e74 | ||
|
|
4d13424b30 | ||
|
|
860c96d0ee | ||
|
|
d9985f9d4d | ||
|
|
1e0b04006b | ||
|
|
d419343939 | ||
|
|
eb3fd55321 | ||
|
|
4aa4301abd | ||
|
|
1d1a917580 | ||
|
|
0255283577 | ||
|
|
2df1f0f358 | ||
|
|
52816dd4b4 | ||
|
|
20e873b6e5 | ||
|
|
ebe710fac2 | ||
|
|
e588913d54 | ||
|
|
1c46e2480c | ||
|
|
3b65679fb0 | ||
|
|
7553ccf14d | ||
|
|
cf693cdce6 | ||
|
|
3fe0023a0a | ||
|
|
13cee674a7 | ||
|
|
64ee74bb38 | ||
|
|
1e855dbb86 | ||
|
|
e0606d115c | ||
|
|
8b159536ce | ||
|
|
2279ff7c58 | ||
|
|
3f6c43e15d | ||
|
|
6c1f68ec00 | ||
|
|
78dad3021a | ||
|
|
4f59f459a0 | ||
|
|
58b24b62ac | ||
|
|
a9b9b58290 | ||
|
|
f90b4667c3 | ||
|
|
3b2b45d6a5 | ||
|
|
8c04bcb981 | ||
|
|
7c4e288fd6 | ||
|
|
1838b63b11 | ||
|
|
6d5d1a48f3 | ||
|
|
bd45022a59 | ||
|
|
b29a3cbfb7 | ||
|
|
a15693cc26 | ||
|
|
5e60d9c248 | ||
|
|
12d7ec8174 | ||
|
|
601a6514a8 | ||
|
|
fc5a0f9dfa | ||
|
|
576ed77d36 | ||
|
|
06cbe56201 | ||
|
|
87eec419e6 | ||
|
|
0e7cef5b6c | ||
|
|
5c0b107142 | ||
|
|
0cfeeb4e45 | ||
|
|
aabb72138a | ||
|
|
0a1f0a90b2 | ||
|
|
493c2b2545 | ||
|
|
0810647c48 | ||
|
|
a3d079cfdf | ||
|
|
c22d1394e7 | ||
|
|
03add792a1 | ||
|
|
ab4495ce65 | ||
|
|
1d3b0e7d13 | ||
|
|
36a458b957 | ||
|
|
23d9d17d9f | ||
|
|
8da993655b | ||
|
|
6792bf9954 | ||
|
|
6cf30b3c5d | ||
|
|
eb23d1b5f9 | ||
|
|
29ba40ef32 | ||
|
|
02f65abf74 | ||
|
|
f5b7de6910 | ||
|
|
ac8e4a0406 | ||
|
|
b6821612af | ||
|
|
8783d74cc7 | ||
|
|
5a52871e7e | ||
|
|
90b2cca984 | ||
|
|
9c37297858 | ||
|
|
1f95a776f0 | ||
|
|
b13410c7d1 | ||
|
|
9aa2d7b593 | ||
|
|
1d956cace4 | ||
|
|
4f5c5c28e6 | ||
|
|
24528e6050 | ||
|
|
7853233ddb | ||
|
|
51f3011947 | ||
|
|
87432a74c2 | ||
|
|
e19ae09966 | ||
|
|
8765f833d2 | ||
|
|
c22202eac4 | ||
|
|
a698373064 | ||
|
|
b43c08b5d2 | ||
|
|
2002068d81 | ||
|
|
bb0d7f3f56 | ||
|
|
c743182211 | ||
|
|
22586dd947 | ||
|
|
f61dc4fc84 | ||
|
|
144d704a5d | ||
|
|
f30a18844e | ||
|
|
043a622e67 | ||
|
|
a602dff739 | ||
|
|
863dfed4ec | ||
|
|
ceb8fb1cb2 | ||
|
|
21fed49d7b | ||
|
|
c9be643e76 | ||
|
|
7287093772 | ||
|
|
2112523b4c | ||
|
|
34506c3039 | ||
|
|
f94c2d7948 | ||
|
|
294d7ae9e9 | ||
|
|
a0d47733a9 | ||
|
|
581bed2a33 | ||
|
|
f9c4028c60 | ||
|
|
9752ce8e92 | ||
|
|
3cfc95cb02 | ||
|
|
3d12c3270e | ||
|
|
812b8a0fb7 | ||
|
|
e5262f0b32 | ||
|
|
42ee5d7355 | ||
|
|
5a283b1c3c | ||
|
|
f9ec9d5d56 | ||
|
|
3b060c73b2 | ||
|
|
85c16de32b | ||
|
|
9aa74afff1 | ||
|
|
f21af0c3c3 | ||
|
|
af101d57fe | ||
|
|
1ad5482a22 | ||
|
|
805a5a4e96 | ||
|
|
08a8acb9ea | ||
|
|
dd474cdc08 | ||
|
|
84c16bc983 | ||
|
|
2589ddcdf6 | ||
|
|
eb8dfda8fa | ||
|
|
9edb72d065 | ||
|
|
406116a230 | ||
|
|
8a165ec181 | ||
|
|
5e438c685d | ||
|
|
a98621b40d | ||
|
|
01ee7deeff | ||
|
|
3b64aeb97b | ||
|
|
594efb5e96 | ||
|
|
8af4336428 | ||
|
|
115b6f0b30 | ||
|
|
2bad404913 | ||
|
|
7e633dbdd5 | ||
|
|
2ae020e70a | ||
|
|
94cbc629cd | ||
|
|
4f1d1c2094 | ||
|
|
cd96d2f5cd | ||
|
|
576160129a | ||
|
|
754b9d9edc | ||
|
|
b5c656f308 | ||
|
|
a41b1673e5 | ||
|
|
a22aaa0a3e | ||
|
|
5c7d6659cf | ||
|
|
f39d11eb9d | ||
|
|
227221ff67 | ||
|
|
27d229c250 | ||
|
|
754498be0b | ||
|
|
7bfc7908ab |
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
.git/
|
||||
.github/
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
venv/
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
.serena/
|
||||
.logs/
|
||||
@@ -0,0 +1 @@
|
||||
custom: https://wu-clan.github.io/sponsor/
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Release changelog
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Check version']
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
|
||||
- uses: rhysd/changelog-from-release/action@v3
|
||||
with:
|
||||
file: CHANGELOG.md
|
||||
pull_request: true
|
||||
commit_summary_template: 'Update the changelog for %s'
|
||||
args: -c
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: lint ${{ matrix.python-version }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --only-group lint
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
chmod 755 backend/scripts/lint.sh
|
||||
./backend/scripts/lint.sh
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Check version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: check version
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Check the package version
|
||||
uses: samuelcolvin/check-python-version@v5
|
||||
with:
|
||||
version_file_path: backend/__init__.py
|
||||
+13
-6
@@ -1,10 +1,17 @@
|
||||
__pycache__/
|
||||
.idea/
|
||||
.env
|
||||
.vscode/
|
||||
.cursor/
|
||||
.DS_Store
|
||||
venv/
|
||||
.mypy_cache/
|
||||
backend/app/log/
|
||||
backend/app/alembic/versions/
|
||||
backend/app/static/media/
|
||||
.venv/
|
||||
.python-version
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
logs/
|
||||
.claude/
|
||||
.serena/
|
||||
.agents/
|
||||
.superpowers/
|
||||
superpowers/
|
||||
.codegraph/
|
||||
.pi/
|
||||
|
||||
+27
-7
@@ -1,19 +1,39 @@
|
||||
default_language_version:
|
||||
python: '>= 3.10'
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: end-of-file-fixer
|
||||
- id: requirements-txt-fixer
|
||||
- id: check-json
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
|
||||
- repo: https://github.com/tombi-toml/tombi-pre-commit
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: tombi-lint
|
||||
args: ["--offline"]
|
||||
- id: tombi-format
|
||||
args: ["--offline"]
|
||||
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.1.6
|
||||
rev: v0.16.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-check
|
||||
args:
|
||||
- '--config'
|
||||
- '.ruff.toml'
|
||||
# https://github.com/astral-sh/ruff-pre-commit/issues/64
|
||||
- '--fix'
|
||||
- '--unsafe-fixes'
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.12.3
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- id: uv-export
|
||||
args:
|
||||
- '-o'
|
||||
- 'requirements.txt'
|
||||
- '--no-hashes'
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
cache-dir = "./.ruff_cache"
|
||||
|
||||
[per-file-ignores]
|
||||
"backend/app/api/v1/*.py" = ["TCH"]
|
||||
"backend/app/models/*.py" = ["TCH003"]
|
||||
"backend/app/**/__init__.py" = ["F401"]
|
||||
"backend/app/tests/*.py" = ["E402"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"E",
|
||||
"F",
|
||||
"I",
|
||||
"W505",
|
||||
"PT018",
|
||||
"SIM101",
|
||||
"SIM114",
|
||||
"PGH004",
|
||||
"PLE1142",
|
||||
"RUF100",
|
||||
"F404",
|
||||
"TCH",
|
||||
"UP007"
|
||||
]
|
||||
|
||||
[lint.flake8-pytest-style]
|
||||
mark-parentheses = false
|
||||
parametrize-names-type = "list"
|
||||
parametrize-values-row-type = "list"
|
||||
parametrize-values-type = "tuple"
|
||||
|
||||
[lint.flake8-unused-arguments]
|
||||
ignore-variadic-names = true
|
||||
|
||||
[lint.isort]
|
||||
lines-between-types = 1
|
||||
order-by-type = true
|
||||
|
||||
[format]
|
||||
quote-style = "single"
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "FBA Plugin Manifest Schema",
|
||||
"description": "JSON Schema for FastAPI Best Architecture plugin.toml files. See: https://fastapi-practices.github.io/fastapi_best_architecture_docs/plugin/dev.html",
|
||||
|
||||
"type": "object",
|
||||
"required": ["plugin"],
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"description": "Plugin metadata",
|
||||
"required": ["summary", "version", "description", "author", "tags"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Icon path (plugin repository icon path or URL)"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"description": "Brief summary (1-100 characters)"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Plugin version (semver format: x.y.z)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Detailed description (1-500 characters)"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 50,
|
||||
"description": "Plugin author (1-50 characters)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Plugin tags for categorization",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["ai", "mcp", "agent", "auth", "storage", "notification", "task", "payment", "other"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
},
|
||||
"database": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Supported databases. Required for app-level and extend-level plugins, optional for capability-level plugins without models.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql"]
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "array",
|
||||
"description": "List of plugin names this plugin depends on (used for startup order)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"x-tombi-array-values-order": "ascending"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"app": {
|
||||
"type": "object",
|
||||
"description": "Application configuration. For app-level plugins: use 'router'. For extend-level plugins: use 'extend'.",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
"extend": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Parent app folder name (for extension-level plugins)"
|
||||
},
|
||||
"router": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Router instances (for application-level plugins)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"x-tombi-array-values-order": "version-sort"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": "Plugin base configuration (hot-pluggable, uppercase keys only)",
|
||||
"x-tombi-additional-key-label": "SETTING_NAME",
|
||||
"x-tombi-table-keys-order": "ascending",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Z][A-Z0-9_]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "number" },
|
||||
{ "type": "boolean" },
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"api": {
|
||||
"type": "object",
|
||||
"description": "API endpoint configurations (for extension-level plugins). The key (e.g., 'xxx' in api.xxx) corresponds to the API filename without extension.",
|
||||
"x-tombi-additional-key-label": "api_filename",
|
||||
"x-tombi-table-keys-order": "ascending",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["prefix", "tags"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": {"properties": "schema"},
|
||||
"properties": {
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^/[a-zA-Z0-9_/-]*$",
|
||||
"description": "URL prefix for the API (must start with '/', allowed chars: a-z, A-Z, 0-9, _, -, /)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "OpenAPI tags for Swagger documentation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1651
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
## Backend
|
||||
|
||||
1. Prerequisites
|
||||
|
||||
- Python >= 3.10
|
||||
- Git
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Fork this repository to your GitHub account
|
||||
|
||||
2. Installation and setup
|
||||
|
||||
Clone your forked repository:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/<your account>/fastapi_best_architecture.git
|
||||
```
|
||||
|
||||
Go to the root directory of the project, open the terminal, and run the following command:
|
||||
|
||||
```sh
|
||||
uv run fba init --auto
|
||||
```
|
||||
|
||||
3. Checkout
|
||||
|
||||
Checkout a new branch and make your changes
|
||||
|
||||
```shell
|
||||
git checkout -b your-new-feature-branch
|
||||
```
|
||||
|
||||
4. Format and Lint
|
||||
|
||||
```shell
|
||||
fba format
|
||||
```
|
||||
|
||||
5. Commit and push
|
||||
|
||||
Commit your changes and push your branch to the GitHub.
|
||||
|
||||
6. PR
|
||||
|
||||
Create a PR via GitHub
|
||||
|
||||
## Scripts
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> The following script may not apply to the Windows platform
|
||||
>
|
||||
> It is recommended to execute under the backend directory, and chmod authorization may be required
|
||||
|
||||
- `migrate.sh`: Perform automatic database migration
|
||||
|
||||
- `scripts/format.sh`: Perform ruff format with preview
|
||||
|
||||
- `scripts/lint.sh`: Perform prek formatting
|
||||
|
||||
- `scripts/export.sh`: Execute uv export dependency package
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
|
||||
ARG SERVER_TYPE=fba_server
|
||||
|
||||
# === Python environment from uv ===
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder
|
||||
|
||||
# Used for build Python packages
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc make python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . /fba
|
||||
|
||||
WORKDIR /fba
|
||||
|
||||
# Configure uv environment
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_NO_CACHE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/usr/local
|
||||
|
||||
# Install dependencies with cache
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-default-groups --group server --no-install-project
|
||||
|
||||
# Preinstall plugin dependencies
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
|
||||
|
||||
# === Runtime base server image ===
|
||||
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS base_server
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /fba /fba
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
|
||||
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
# === FastAPI server image ===
|
||||
FROM base_server AS fba_server
|
||||
|
||||
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery Worker image ===
|
||||
FROM base_server AS fba_celery_worker
|
||||
|
||||
COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery Beat image ===
|
||||
FROM base_server AS fba_celery_beat
|
||||
|
||||
COPY deploy/backend/supervisor/fba_celery_beat.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# === Celery Flower image ===
|
||||
FROM base_server AS fba_celery_flower
|
||||
|
||||
COPY deploy/backend/supervisor/fba_celery_flower.conf /etc/supervisor/conf.d/
|
||||
|
||||
RUN mkdir -p /var/log/fba
|
||||
|
||||
EXPOSE 8555
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
|
||||
# Build image
|
||||
FROM ${SERVER_TYPE}
|
||||
@@ -1,28 +1,35 @@
|
||||
<div align="center">
|
||||
|
||||
<img alt="The logo includes the abstract combination of the three letters FBA, forming a lightning bolt that seems to spread out from the ground" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
|
||||
|
||||
# FastAPI Best Architecture
|
||||
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
> [!TIP]
|
||||
> You are viewing the pydantic-v1 branch, which is locked and no longer provides updates and fixes
|
||||
Enterprise-level backend architecture solution
|
||||
|
||||
English | [简体中文](./README.zh-CN.md)
|
||||
|
||||
FastAPI framework based on the front-end and back-end separation of the middle and back-end solutions, follow
|
||||
the [pseudo three-tier architecture](#pseudo-three-tier-architecture) design, support for **python3.10** and above
|
||||
versions
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||

|
||||

|
||||

|
||||
[](https://pydantic.dev)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://github.com/astral-sh/uv)
|
||||

|
||||
[](https://discord.com/invite/yNN3wTbVAC)
|
||||

|
||||
[](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
Its purpose is to allow you to use it directly as the infrastructure of your new project, this repository as a template
|
||||
library open to any person or enterprise can be used for free!
|
||||
</div>
|
||||
|
||||
**Continuously updated and maintained**
|
||||
## Three-tier architecture
|
||||
|
||||
## Pseudo three-tier architecture
|
||||
The MVC architecture is common in Python Web as a general design pattern, but the three-tier architecture is more
|
||||
fascinating
|
||||
|
||||
The mvc architecture is a common design pattern in python web, but the three-tier architecture is even more fascinating.
|
||||
|
||||
In python web development, there is no common standard for the concept of three-tier architecture, so we'll call it a
|
||||
pseudo three-tier architecture here
|
||||
However, please note that we do not have a traditional multi-app (microservices) directory structure (django,
|
||||
springBoot...). ), but a self-righteous directory structure that you can give it any twist if you don't like this model!
|
||||
|
||||
| workflow | java | fastapi_best_architecture |
|
||||
|----------------|----------------|---------------------------|
|
||||
@@ -32,171 +39,30 @@ pseudo three-tier architecture here
|
||||
| data access | dao / mapper | crud |
|
||||
| model | model / entity | model |
|
||||
|
||||
## Online preview
|
||||
## Help
|
||||
|
||||
Unfortunately, we don't have the funds to provide an online preview, you can deploy by checking
|
||||
out [local-development](#local-development), or directly using [Docker](#docker-deploy)
|
||||
to deploy, or at [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui)
|
||||
See a preview of some of the screenshots
|
||||
For more details, please check
|
||||
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
|
||||
|
||||
## Features
|
||||
## Sponsors
|
||||
|
||||
- [x] Design with FastAPI PEP 593 Annotated Parameters
|
||||
- [x] Global asynchronous design with async/await + asgiref
|
||||
- [x] Follows Restful API specification
|
||||
- [x] Global SQLAlchemy 2.0 syntax
|
||||
- [x] Casbin RBAC access control model
|
||||
- [x] Celery asynchronous tasks
|
||||
- [x] JWT middleware whitelist authentication
|
||||
- [x] Global customizable time zone time
|
||||
- [x] Docker / Docker-compose deployment
|
||||
- [x] Pytest Unit Testing
|
||||
|
||||
## Built-in features
|
||||
|
||||
1. [x] User management: system user role management, permission assignment
|
||||
2. [x] Department Management: Configure the system organization (company, department, group...)
|
||||
3. [x] Menu Management: Configuration of system menus, user menus, button privilege identification
|
||||
4. [x] Role Management: Assign role menu privileges, assign role routing privileges
|
||||
5. [x] Dictionary Management: Maintain common fixed data or parameters within the system.
|
||||
6. [x] Operation Logs: logging and querying of normal and abnormal system operations.
|
||||
7. [x] Login Authentication: graphical authentication code background authentication login
|
||||
8. [x] Login Logs: Logging and querying of normal and abnormal user logins
|
||||
9. [x] Service Monitoring: server hardware device information and status
|
||||
10. [x] Scheduled tasks: automated tasks, asynchronous tasks, and function invocation are supported
|
||||
11. [x] Interface Documentation: Automatically generate online interactive API interface documentation.
|
||||
|
||||
## Local development
|
||||
|
||||
* Python: 3.10+
|
||||
* Mysql: 8.0+
|
||||
* Redis: The latest stable version is recommended
|
||||
* Nodejs: 14.0+
|
||||
|
||||
### BackEnd
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```shell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Create a database `fba`, choose utf8mb4 encoding
|
||||
3. Install and start Redis
|
||||
4. Create a `.env` file in the `backend/app/` directory
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
touch .env
|
||||
```
|
||||
|
||||
5. Copy `.env.example` to `.env`
|
||||
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
6. Database migration [alembic](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
|
||||
# Generate migration file
|
||||
alembic revision --autogenerate
|
||||
|
||||
# Execute the migration
|
||||
alembic upgrade head
|
||||
```
|
||||
7. Start celery worker and beat
|
||||
|
||||
```shell
|
||||
celery -A tasks worker --loglevel=INFO
|
||||
# Optional, if you don't need to use the scheduled task
|
||||
celery -A tasks beat --loglevel=INFO
|
||||
```
|
||||
|
||||
8. Modify the configuration file as needed
|
||||
9. Execute the `backend/app/main.py` file to start the service
|
||||
10. Browser access: http://127.0.0.1:8000/api/v1/docs
|
||||
|
||||
---
|
||||
|
||||
### Front
|
||||
|
||||
Go to [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) for details
|
||||
|
||||
### Docker deploy
|
||||
|
||||
> [!WARNING]
|
||||
> Default port conflict:8000,3306,6379,5672
|
||||
>
|
||||
> As a best practice, shut down on-premises services before deployment:mysql,redis,rabbitmq...
|
||||
|
||||
1. Go to the directory where the ``docker-compose.yml`` file is located and create the environment variable
|
||||
file ``.env``
|
||||
|
||||
```shell
|
||||
cd deploy/docker-compose/
|
||||
|
||||
cp .env.server ../../backend/app/.env
|
||||
|
||||
# This command is optional
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
2. Modify the configuration file as needed
|
||||
3. Execute the one-click boot command
|
||||
|
||||
```shell
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
4. Wait for the command to complete automatically
|
||||
5. Visit the browser: http://127.0.0.1:8000/api/v1/docs
|
||||
|
||||
## Test data
|
||||
|
||||
Initialize the test data using the `backend/sql/init_test_data.sql` file
|
||||
|
||||
## Development process
|
||||
|
||||
For reference only
|
||||
|
||||
### BackEnd
|
||||
|
||||
1. Define the database model (model) and remember to perform database migration for each change
|
||||
2. Define the data validation model (schema)
|
||||
3. Define routes (router) and views (api)
|
||||
4. Define the business logic (service)
|
||||
5. Write database operations (crud)
|
||||
|
||||
### Front
|
||||
|
||||
Go to [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) for details
|
||||
|
||||
## Test
|
||||
|
||||
Execute unittests via pytest
|
||||
|
||||
1. Create the test database `fba_test`, select utf8mb4 encoding
|
||||
2. Using `backend/sql/create_tables.sql` file to create database tables
|
||||
3. Initialize the test data using the `backend/sql/init_pytest_data.sql` file
|
||||
4. Enter the app directory
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
```
|
||||
|
||||
5. Execute the test command
|
||||
|
||||
```shell
|
||||
pytest -vs --disable-warnings
|
||||
```
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://claude.uy/home">
|
||||
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Contributors
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wu-clan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52145145?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/downdawn" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/41266749?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
|
||||
</a>
|
||||
|
||||
## Special thanks
|
||||
|
||||
@@ -207,37 +73,13 @@ Execute unittests via pytest
|
||||
- [Ruff](https://beta.ruff.rs/docs/)
|
||||
- ...
|
||||
|
||||
## 互动
|
||||
## Interactivity
|
||||
|
||||
有且仅有当前一个频道,请注意辨别真伪
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://t.me/+ZlPhIFkPp7E4NGI1">直链跳转</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Telegram </td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sponsor us
|
||||
|
||||
If this program has helped you, you can sponsor us with some coffee beans :coffee:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/weixin.jpg?raw=true" width="180px" alt="WeChat"/>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/zfb.jpg?raw=true" width="180px" alt="Alipay"/>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/ERC20.jpg?raw=true" width="180px" alt="0x40D5e2304b452256afD9CE2d3d5531dc8d293138"/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">WeChat Pay</td>
|
||||
<td align="center">Ali Pay</td>
|
||||
<td align="center">ERC20</td>
|
||||
</tr>
|
||||
</table>
|
||||
[Discord](https://wu-clan.github.io/homepage/)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of
|
||||
This project is licensed by the terms of
|
||||
the [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) license
|
||||
|
||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
+42
-198
@@ -1,24 +1,33 @@
|
||||
# FastAPI 最佳架构
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
<img alt="Logo 包含了 FBA 三个字母抽象结合,形成了一个类似从地面扩散投影上来的闪电" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
|
||||
|
||||
> [!TIP]
|
||||
> 你正在查看 pydantic-v1 分支,此分支已被锁定,不再提供更新和修复
|
||||
# FastAPI Best Architecture
|
||||
|
||||
企业级后端架构解决方案
|
||||
|
||||
简体中文 | [English](./README.md)
|
||||
|
||||
基于 FastAPI 框架的前后端分离中后台解决方案,遵循[伪三层架构](#伪三层架构)设计, 支持 **python3.10** 及以上版本
|
||||
[](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||

|
||||

|
||||

|
||||
[](https://pydantic.dev)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://github.com/astral-sh/uv)
|
||||

|
||||
[](https://discord.com/invite/yNN3wTbVAC)
|
||||

|
||||
[](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
它的目的是让你可以直接用它作为你新项目的基础架构,本仓库作为模板库公开,任何人或企业均可免费使用
|
||||
</div>
|
||||
|
||||
**持续更新维护中**
|
||||
## 三层架构
|
||||
|
||||
## 伪三层架构
|
||||
mvc 架构作为常规设计模式,在 python web 中很常见,但是三层架构更令人着迷
|
||||
|
||||
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
|
||||
|
||||
在 python web 开发中,三层架构的概念并没有通用标准,所以这里我们称之为伪三层架构
|
||||
但请注意,我们并没有传统的多 app (微服务)目录结构(django、springBoot...),而是自以为是的目录结构,如果您不喜欢这种模式,可以对其进行任意改造!
|
||||
|
||||
| 工作流程 | java | fastapi_best_architecture |
|
||||
|------|----------------|---------------------------|
|
||||
@@ -28,170 +37,29 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
|
||||
| 数据访问 | dao / mapper | crud |
|
||||
| 模型 | model / entity | model |
|
||||
|
||||
## 在线预览
|
||||
## 帮助
|
||||
|
||||
遗憾的是,我们没有资金提供在线预览,您可以通过查看[本地开发](#本地开发)进行部署,或直接使用 [Docker](#docker-部署)
|
||||
进行部署,或者在 [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui)
|
||||
查看部分截图预览
|
||||
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
|
||||
|
||||
## 特征
|
||||
## 赞助商
|
||||
|
||||
- [x] 使用 FastAPI PEP 593 Annotated 参数设计
|
||||
- [x] async/await + asgiref 的全局异步设计
|
||||
- [x] 遵循 Restful API 规范
|
||||
- [x] 全局 SQLAlchemy 2.0 语法
|
||||
- [x] Casbin RBAC 访问控制模型
|
||||
- [x] Celery 异步任务
|
||||
- [x] JWT 中间件白名单认证
|
||||
- [x] 全局自定义时区时间
|
||||
- [x] Docker / Docker-compose 部署
|
||||
- [x] Pytest 单元测试
|
||||
|
||||
## 内置功能
|
||||
|
||||
1. [x] 用户管理:系统用户角色管理,权限分配
|
||||
2. [x] 部门管理:配置系统组织机构(公司、部门、小组...)
|
||||
3. [x] 菜单管理:配置系统菜单,用户菜单,按钮权限标识
|
||||
4. [x] 角色管理:角色菜单权限分配,角色路由权限分配
|
||||
5. [x] 字典管理:维护系统内部常用固定数据或参数
|
||||
6. [x] 操作日志:系统正常操作和异常操作日志记录和查询
|
||||
7. [x] 登录认证:图形验证码后台认证登录
|
||||
8. [x] 登录日志:用户正常登录和异常登录的日志记录与查询
|
||||
9. [x] 服务监控:服务器硬件设备信息与状态
|
||||
10. [x] 定时任务:自动化任务,异步任务,支持函数调用
|
||||
11. [x] 接口文档:自动生成在线交互式 API 接口文档
|
||||
|
||||
## 本地开发
|
||||
|
||||
* Python 3.10+
|
||||
* Mysql 8.0+
|
||||
* Redis 推荐最新稳定版
|
||||
* Nodejs 14.0+
|
||||
|
||||
### 后端
|
||||
|
||||
1. 安装依赖项
|
||||
|
||||
```shell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. 创建一个数据库 `fba`,选择 utf8mb4 编码
|
||||
3. 安装并启动 Redis
|
||||
4. 在 `backend/app/` 目录下创建一个 `.env` 文件
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
touch .env
|
||||
```
|
||||
|
||||
5. 复制 `.env.example` 到 `.env`
|
||||
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
6. 数据库迁移 [alembic](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
|
||||
# 生成迁移文件
|
||||
alembic revision --autogenerate
|
||||
|
||||
# 执行迁移
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
7. 启动 celery worker 和 beat
|
||||
|
||||
```shell
|
||||
celery -A tasks worker --loglevel=INFO
|
||||
# 可选,如果您不需要使用计划任务
|
||||
celery -A tasks beat --loglevel=INFO
|
||||
```
|
||||
|
||||
8. 按需修改配置文件
|
||||
9. 执行 `backend/app/main.py` 文件启动服务
|
||||
10. 浏览器访问:http://127.0.0.1:8000/api/v1/docs
|
||||
|
||||
---
|
||||
|
||||
### 前端
|
||||
|
||||
跳转 [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) 查看详情
|
||||
|
||||
### Docker 部署
|
||||
|
||||
> [!WARNING]
|
||||
> 默认端口冲突:8000,3306,6379,5672
|
||||
>
|
||||
> 最佳做法是在部署之前关闭本地服务:mysql,redis,rabbitmq...
|
||||
|
||||
1. 进入 `docker-compose.yml` 文件所在目录,创建环境变量文件`.env`
|
||||
|
||||
```shell
|
||||
cd deploy/docker-compose/
|
||||
|
||||
cp .env.server ../../backend/app/.env
|
||||
|
||||
# 此命令为可选
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
2. 按需修改配置文件
|
||||
3. 执行一键启动命令
|
||||
|
||||
```shell
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
4. 等待命令自动完成
|
||||
5. 浏览器访问:http://127.0.0.1:8000/api/v1/docs
|
||||
|
||||
## 测试数据
|
||||
|
||||
使用 `backend/sql/init_test_data.sql` 文件初始化测试数据
|
||||
|
||||
## 开发流程
|
||||
|
||||
仅供参考
|
||||
|
||||
### 后端:
|
||||
|
||||
1. 定义数据库模型(model),每次变化记得执行数据库迁移
|
||||
2. 定义数据验证模型(schema)
|
||||
3. 定义路由(router)和视图(api)
|
||||
4. 定义业务逻辑(service)
|
||||
5. 编写数据库操作(crud)
|
||||
|
||||
### 前端
|
||||
|
||||
跳转 [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) 查看详情
|
||||
|
||||
## 测试
|
||||
|
||||
通过 pytest 执行单元测试
|
||||
|
||||
1. 创建测试数据库 `fba_test`,选择 utf8mb4 编码
|
||||
2. 使用 `backend/sql/create_tables.sql` 文件创建数据库表
|
||||
3. 使用 `backend/sql/init_pytest_data.sql` 文件初始化测试数据
|
||||
4. 进入app目录
|
||||
|
||||
```shell
|
||||
cd backend/app/
|
||||
```
|
||||
|
||||
5. 执行测试命令
|
||||
|
||||
```shell
|
||||
pytest -vs --disable-warnings
|
||||
```
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://claude.uy/home">
|
||||
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 贡献者
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wu-clan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52145145?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/downdawn" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/41266749?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
|
||||
</a>
|
||||
|
||||
## 特别鸣谢
|
||||
|
||||
@@ -204,34 +72,10 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
|
||||
|
||||
## 互动
|
||||
|
||||
有且仅有当前一个频道,请注意辨别真伪
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://t.me/+ZlPhIFkPp7E4NGI1">直链跳转</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Telegram </td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 赞助我们
|
||||
|
||||
如果此项目能够帮助到你,你可以赞助作者一些咖啡豆表示鼓励 :coffee:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/weixin.jpg?raw=true" width="180px" alt="Wechat"/>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/zfb.jpg?raw=true" width="180px" alt="Alipay"/>
|
||||
<td><img src="https://github.com/wu-clan/image/blob/master/pay/ERC20.jpg?raw=true" width="180px" alt="0x40D5e2304b452256afD9CE2d3d5531dc8d293138"/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">微信</td>
|
||||
<td align="center">支付宝</td>
|
||||
<td align="center">ERC20</td>
|
||||
</tr>
|
||||
</table>
|
||||
[Discord](https://wu-clan.github.io/homepage/)
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目根据 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
|
||||
本项目由 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
|
||||
|
||||
[](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /fba
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& sed -i 's|security.debian.org/debian-security|mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 某些包可能存在同步不及时导致安装失败的情况,可更改为官方源:https://pypi.org/simple
|
||||
RUN pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple \
|
||||
&& pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
ENV TZ = Asia/Shanghai
|
||||
|
||||
RUN mkdir -p /var/log/fastapi_server
|
||||
|
||||
COPY ./deploy/fastapi_server.conf /etc/supervisor/conf.d/
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["uvicorn", "backend.app.main:app", "--host", "127.0.0.1", "--port", "8000"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Env
|
||||
ENVIRONMENT='dev'
|
||||
# Database
|
||||
DATABASE_TYPE='postgresql'
|
||||
DATABASE_HOST='127.0.0.1'
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USER='postgres'
|
||||
DATABASE_PASSWORD='123456'
|
||||
# 额外数据源
|
||||
# DATABASE_SOURCES='{"read": "postgresql+asyncpg://user:password@host:5432/fba"}'
|
||||
DATABASE_SOURCES='{}'
|
||||
# Redis
|
||||
REDIS_HOST='127.0.0.1'
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=''
|
||||
REDIS_DATABASE=0
|
||||
# Token
|
||||
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
|
||||
# [ App ] task
|
||||
# Celery
|
||||
CELERY_BROKER_REDIS_DATABASE=1
|
||||
# Rabbitmq
|
||||
CELERY_RABBITMQ_HOST='127.0.0.1'
|
||||
CELERY_RABBITMQ_PORT=5672
|
||||
CELERY_RABBITMQ_USERNAME='guest'
|
||||
CELERY_RABBITMQ_PASSWORD='guest'
|
||||
# [ Plugin ] oauth2
|
||||
OAUTH2_GITHUB_CLIENT_ID='test'
|
||||
OAUTH2_GITHUB_CLIENT_SECRET='test'
|
||||
OAUTH2_GOOGLE_CLIENT_ID='test'
|
||||
OAUTH2_GOOGLE_CLIENT_SECRET='test'
|
||||
# [ Plugin ] email
|
||||
EMAIL_USERNAME=''
|
||||
EMAIL_PASSWORD=''
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
.env
|
||||
alembic/versions/
|
||||
static/media/
|
||||
static/ai_buddy/
|
||||
*.log
|
||||
celerybeat-schedule.*
|
||||
@@ -0,0 +1,18 @@
|
||||
# FastAPI Best Architecture - Backend
|
||||
|
||||
## Docker
|
||||
|
||||
1. Make sure you're at the root of the project
|
||||
2. Run the following Docker command to build container:
|
||||
|
||||
```shell
|
||||
docker build -f Dockerfile -t fba_backend_independent .
|
||||
```
|
||||
|
||||
3. Start container
|
||||
|
||||
Native boot needs to change `127.0.0.1` in `.env` to `host.docker.internal`
|
||||
|
||||
```shell
|
||||
docker run -d -p 8000:8000 --name fba_server fba_backend_independent
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from backend.utils.dynamic_import import get_all_models
|
||||
|
||||
|
||||
def _register_model_globals() -> None:
|
||||
"""导入所有模型并注册到 backend 模块命名空间"""
|
||||
for model_obj in get_all_models():
|
||||
model_name = model_obj.name if isinstance(model_obj, sa.Table) else model_obj.__name__
|
||||
if model_name not in globals():
|
||||
globals()[model_name] = model_obj
|
||||
|
||||
|
||||
_register_model_globals()
|
||||
|
||||
|
||||
__version__ = '1.15.1'
|
||||
@@ -0,0 +1,34 @@
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,3 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
|
||||
If you add a new app to the framework, be sure to read the contents of the `env.py` file.
|
||||
@@ -0,0 +1,118 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
from backend.core import path_conf
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.database.db import get_database_url
|
||||
|
||||
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
|
||||
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(BASE_PATH / config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = MappedBase.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
get_database_url().render_as_string(hide_password=False).replace('%', '%%'),
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
def process_revision_directives(context, revision, directives) -> None: # ruff:ignore[missing-type-function-argument]
|
||||
"""当迁移无变化时,不生成迁移记录"""
|
||||
if config.cmd_opts.autogenerate:
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
print('\nNo changes in model detected')
|
||||
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
transaction_per_migration=True,
|
||||
process_revision_directives=process_revision_directives,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -7,6 +7,7 @@ Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import backend.common.model
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Env: dev、pro
|
||||
ENVIRONMENT='dev'
|
||||
# MySQL
|
||||
DB_HOST='127.0.0.1'
|
||||
DB_PORT=3306
|
||||
DB_USER='root'
|
||||
DB_PASSWORD='123456'
|
||||
# Redis
|
||||
REDIS_HOST='127.0.0.1'
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=''
|
||||
REDIS_DATABASE=0
|
||||
# Celery
|
||||
CELERY_REDIS_HOST='127.0.0.1'
|
||||
CELERY_REDIS_PORT=6379
|
||||
CELERY_REDIS_PASSWORD=''
|
||||
CELERY_BROKER_REDIS_DATABASE=1
|
||||
CELERY_BACKEND_REDIS_DATABASE=2
|
||||
# Rabbitmq
|
||||
RABBITMQ_HOST='127.0.0.1'
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USERNAME='guest'
|
||||
RABBITMQ_PASSWORD='guest'
|
||||
# Token
|
||||
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
|
||||
# Opera Log
|
||||
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.auth import router as auth_router
|
||||
from backend.app.admin.api.v1.log import router as log_router
|
||||
from backend.app.admin.api.v1.monitor import router as monitor_router
|
||||
from backend.app.admin.api.v1.sys import router as sys_router
|
||||
from backend.core.conf import settings
|
||||
|
||||
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
|
||||
|
||||
v1.include_router(auth_router)
|
||||
v1.include_router(sys_router)
|
||||
v1.include_router(log_router)
|
||||
v1.include_router(monitor_router)
|
||||
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.auth.auth import router as auth_router
|
||||
from backend.app.admin.api.v1.auth.captcha import router as captcha_router
|
||||
|
||||
router = APIRouter(prefix='/auth')
|
||||
|
||||
router.include_router(auth_router, tags=['授权'])
|
||||
router.include_router(captcha_router, tags=['验证码'])
|
||||
@@ -0,0 +1,58 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from pyrate_limiter import Duration, Rate
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
|
||||
from backend.app.admin.schema.user import AuthLoginParam
|
||||
from backend.app.admin.service.auth_service import auth_service
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
from backend.utils.limiter import RateLimiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
|
||||
async def login_swagger(
|
||||
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
|
||||
) -> GetSwaggerToken:
|
||||
token, user = await auth_service.swagger_login(db=db, obj=obj)
|
||||
return GetSwaggerToken(access_token=token, user=user) # type: ignore
|
||||
|
||||
|
||||
@router.post(
|
||||
'/login',
|
||||
summary='用户登录',
|
||||
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
|
||||
dependencies=[Depends(RateLimiter(Rate(5, Duration.MINUTE)))],
|
||||
)
|
||||
async def login(
|
||||
db: CurrentSessionTransaction,
|
||||
response: Response,
|
||||
obj: AuthLoginParam,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> ResponseSchemaModel[GetLoginToken]:
|
||||
data = await auth_service.login(db=db, response=response, obj=obj, background_tasks=background_tasks)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
|
||||
async def get_codes(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[str]]:
|
||||
codes = await auth_service.get_codes(db=db, request=request)
|
||||
return response_base.success(data=codes)
|
||||
|
||||
|
||||
@router.post('/refresh', summary='刷新 token')
|
||||
async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
|
||||
data = await auth_service.refresh_token(db=db, request=request, response=response)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/logout', summary='用户登出')
|
||||
async def logout(request: Request, response: Response) -> ResponseModel:
|
||||
await auth_service.logout(request=request, response=response)
|
||||
return response_base.success()
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from fast_captcha import img_captcha
|
||||
from fastapi import APIRouter, Depends
|
||||
from pyrate_limiter import Duration, Rate
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.app.admin.schema.captcha import GetCaptchaDetail
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import CurrentSession
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_login_config
|
||||
from backend.utils.limiter import RateLimiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'/captcha',
|
||||
summary='获取登录验证码',
|
||||
dependencies=[Depends(RateLimiter(Rate(5, Duration.SECOND * 30)))],
|
||||
)
|
||||
async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
|
||||
await load_login_config(db)
|
||||
img, code = await run_in_threadpool(img_captcha, img_byte='base64')
|
||||
captcha_uuid = str(uuid.uuid4())
|
||||
await redis_client.set(
|
||||
f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
|
||||
code,
|
||||
ex=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
|
||||
)
|
||||
data = GetCaptchaDetail(
|
||||
is_enabled=settings.LOGIN_CAPTCHA_ENABLED,
|
||||
expire_seconds=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
|
||||
uuid=captcha_uuid,
|
||||
image=img,
|
||||
)
|
||||
return response_base.success(data=data)
|
||||
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.log.login_log import router as login_log
|
||||
from backend.app.admin.api.v1.log.opera_log import router as opera_log
|
||||
|
||||
router = APIRouter(prefix='/logs')
|
||||
|
||||
router.include_router(login_log, prefix='/login', tags=['登录日志'])
|
||||
router.include_router(opera_log, prefix='/opera', tags=['操作日志'])
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
|
||||
from backend.app.admin.service.login_log_service import login_log_service
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取登录日志',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_login_logs_paginated(
|
||||
db: CurrentSession,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
ip: Annotated[str | None, Query(description='IP 地址')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
|
||||
page_data = await login_log_service.get_list(db=db, username=username, status=status, ip=ip)
|
||||
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除登录日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:login:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_login_logs(db: CurrentSessionTransaction, obj: DeleteLoginLogParam) -> ResponseModel:
|
||||
count = await login_log_service.delete(db=db, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/all',
|
||||
summary='清空登录日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:login:clear')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_all_login_logs(db: CurrentSessionTransaction) -> ResponseModel:
|
||||
await login_log_service.delete_all(db=db)
|
||||
return response_base.success()
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
|
||||
from backend.app.admin.service.opera_log_service import opera_log_service
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取操作日志',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_opera_logs_paginated(
|
||||
db: CurrentSession,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
ip: Annotated[str | None, Query(description='IP 地址')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
|
||||
page_data = await opera_log_service.get_list(db=db, username=username, status=status, ip=ip)
|
||||
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除操作日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:opera:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_opera_logs(db: CurrentSessionTransaction, obj: DeleteOperaLogParam) -> ResponseModel:
|
||||
count = await opera_log_service.delete(db=db, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/all',
|
||||
summary='清空操作日志',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('log:opera:clear')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_all_opera_logs(db: CurrentSessionTransaction) -> ResponseModel:
|
||||
await opera_log_service.delete_all(db=db)
|
||||
return response_base.success()
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.monitor.online import router as token_router
|
||||
from backend.app.admin.api.v1.monitor.redis import router as redis_router
|
||||
from backend.app.admin.api.v1.monitor.server import router as server_router
|
||||
|
||||
router = APIRouter(prefix='/monitors')
|
||||
|
||||
router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
|
||||
router.include_router(server_router, prefix='/server', tags=['服务器监控'])
|
||||
router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from backend.app.admin.schema.token import GetTokenDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsSuperUser, jwt_decode
|
||||
from backend.common.security.token import revoke_token
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.common.dataclasses import TokenPayload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='获取在线用户', dependencies=[DependsSuperUser])
|
||||
async def get_sessions( # ruff:ignore[complex-structure]
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
) -> ResponseSchemaModel[list[GetTokenDetail]]:
|
||||
users_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users'
|
||||
user_ids = list(await redis_client.smembers(users_key))
|
||||
if not user_ids:
|
||||
return response_base.success(data=[])
|
||||
|
||||
session_sets = await redis_client.smembers_many([
|
||||
f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}' for user_id in user_ids
|
||||
])
|
||||
session_refs: list[tuple[str, str]] = []
|
||||
for user_id, members in zip(user_ids, session_sets, strict=True):
|
||||
session_refs.extend((user_id, session_uuid) for session_uuid in members)
|
||||
if not session_refs:
|
||||
await redis_client.srem(users_key, *user_ids)
|
||||
return response_base.success(data=[])
|
||||
|
||||
tokens = await redis_client.mget_batched([
|
||||
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}' for user_id, session_uuid in session_refs
|
||||
])
|
||||
token_payloads: list[TokenPayload] = []
|
||||
live_user_ids: set[str] = set()
|
||||
for (user_id, _session_uuid), token in zip(session_refs, tokens, strict=True):
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
token_payloads.append(jwt_decode(token))
|
||||
except errors.TokenError:
|
||||
continue
|
||||
live_user_ids.add(user_id)
|
||||
stale_user_ids = [user_id for user_id in user_ids if user_id not in live_user_ids]
|
||||
if stale_user_ids:
|
||||
await redis_client.srem(users_key, *stale_user_ids)
|
||||
if not token_payloads:
|
||||
return response_base.success(data=[])
|
||||
|
||||
extra_infos = await redis_client.mget_batched([
|
||||
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{item.user_id}:{item.session_uuid}' for item in token_payloads
|
||||
])
|
||||
sid_sets = await redis_client.smembers_many([
|
||||
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{item.session_uuid}' for item in token_payloads
|
||||
])
|
||||
sid_list = [sid for members in sid_sets for sid in members]
|
||||
sid_values = await redis_client.mget_batched([
|
||||
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}' for sid in sid_list
|
||||
])
|
||||
sid_session_map = dict(zip(sid_list, sid_values, strict=True))
|
||||
online_sessions = {
|
||||
item.session_uuid
|
||||
for item, members in zip(token_payloads, sid_sets, strict=True)
|
||||
if any(sid_session_map.get(sid) == item.session_uuid for sid in members)
|
||||
}
|
||||
data: list[GetTokenDetail] = []
|
||||
for token_payload, extra_info in zip(token_payloads, extra_infos, strict=True):
|
||||
info: dict[str, Any] = {}
|
||||
if extra_info:
|
||||
try:
|
||||
parsed = json.loads(extra_info)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
info = parsed
|
||||
if info.get('swagger') is not None:
|
||||
continue
|
||||
if username is not None and username != info.get('username'):
|
||||
continue
|
||||
data.append(
|
||||
GetTokenDetail(
|
||||
id=token_payload.user_id,
|
||||
session_uuid=token_payload.session_uuid,
|
||||
username=info.get('username', '未知'),
|
||||
nickname=info.get('nickname', '未知'),
|
||||
ip=info.get('ip', '未知'),
|
||||
os=info.get('os', '未知'),
|
||||
browser=info.get('browser', '未知'),
|
||||
device=info.get('device', '未知'),
|
||||
status=StatusType.enable if token_payload.session_uuid in online_sessions else StatusType.disable,
|
||||
last_login_time=info.get('last_login_time', '未知'),
|
||||
expire_time=token_payload.expire_time,
|
||||
)
|
||||
)
|
||||
data.sort(key=lambda item: (item.id, item.session_uuid))
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='强制下线',
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def delete_session(
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
session_uuid: Annotated[str, Query(description='会话 UUID')],
|
||||
) -> ResponseModel:
|
||||
await revoke_token(pk, session_uuid)
|
||||
return response_base.success()
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.format import fmt_seconds
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='Redis 监控', dependencies=[DependsJwtAuth])
|
||||
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
|
||||
info = await redis_client.info()
|
||||
db_size = await redis_client.dbsize()
|
||||
|
||||
server_info = RedisServerInfo(
|
||||
redis_version=str(info.get('redis_version', '')),
|
||||
redis_mode=str(info.get('redis_mode', '')),
|
||||
role=str(info.get('role', '')),
|
||||
tcp_port=str(info.get('tcp_port', '')),
|
||||
uptime=str(fmt_seconds(int(info.get('uptime_in_seconds', 0)))),
|
||||
connected_clients=str(info.get('connected_clients', '')),
|
||||
blocked_clients=str(info.get('blocked_clients', '')),
|
||||
used_memory_human=str(info.get('used_memory_human', '')),
|
||||
used_memory_rss_human=str(info.get('used_memory_rss_human', '')),
|
||||
maxmemory_human=str(info.get('maxmemory_human', '0B')),
|
||||
mem_fragmentation_ratio=str(info.get('mem_fragmentation_ratio', '0')),
|
||||
instantaneous_ops_per_sec=str(info.get('instantaneous_ops_per_sec', '')),
|
||||
total_commands_processed=str(info.get('total_commands_processed', '')),
|
||||
rejected_connections=str(info.get('rejected_connections', '')),
|
||||
keys_num=str(db_size),
|
||||
)
|
||||
|
||||
command_stats = await redis_client.info('commandstats')
|
||||
stats_list = []
|
||||
for key, value in command_stats.items():
|
||||
if isinstance(value, dict):
|
||||
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
|
||||
|
||||
data = RedisMonitorInfo(info=server_info, stats=stats_list)
|
||||
return response_base.success(data=data)
|
||||
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timezone as tz
|
||||
|
||||
import psutil
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.app.admin.schema.monitor import (
|
||||
CpuInfo,
|
||||
DiskInfo,
|
||||
MemInfo,
|
||||
ServerMonitorInfo,
|
||||
ServiceInfo,
|
||||
SysInfo,
|
||||
)
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsSuperUser
|
||||
from backend.utils.format import fmt_bytes, fmt_seconds
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='Server 监控', dependencies=[DependsSuperUser])
|
||||
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # ruff:ignore[complex-structure]
|
||||
def get_all_info() -> ServerMonitorInfo: # ruff:ignore[complex-structure]
|
||||
# CPU 信息
|
||||
cpu_data = {
|
||||
'physical_num': psutil.cpu_count(logical=False) or 0,
|
||||
'logical_num': psutil.cpu_count(logical=True) or 0,
|
||||
'max_freq': 0.0,
|
||||
'min_freq': 0.0,
|
||||
'current_freq': 0.0,
|
||||
'usage': round(psutil.cpu_percent(interval=0.1), 2),
|
||||
}
|
||||
|
||||
try:
|
||||
if hasattr(psutil, 'cpu_freq'):
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
if cpu_freq:
|
||||
cpu_data.update({
|
||||
'max_freq': round(cpu_freq.max, 2),
|
||||
'min_freq': round(cpu_freq.min, 2),
|
||||
'current_freq': round(cpu_freq.current, 2),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cpu = CpuInfo(**cpu_data)
|
||||
|
||||
# 内存信息
|
||||
mem = psutil.virtual_memory()
|
||||
gb_factor = 1024**3
|
||||
mem_info = MemInfo(
|
||||
total=round(mem.total / gb_factor, 2),
|
||||
used=round(mem.used / gb_factor, 2),
|
||||
free=round(mem.available / gb_factor, 2),
|
||||
usage=round(mem.percent, 2),
|
||||
)
|
||||
|
||||
# 系统信息
|
||||
hostname = socket.gethostname()
|
||||
ip = '127.0.0.1'
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(0.5)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
except (TimeoutError, socket.gaierror, OSError):
|
||||
pass
|
||||
sys_info = SysInfo(name=hostname, os=platform.system(), ip=ip, arch=platform.machine())
|
||||
|
||||
# 磁盘信息
|
||||
disk_list = []
|
||||
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
|
||||
seen_devices = set()
|
||||
for partition in psutil.disk_partitions(all=False):
|
||||
# 跳过虚拟文件系统
|
||||
if partition.fstype.lower() in exclude_fstypes:
|
||||
continue
|
||||
# 跳过重复设备(同一设备的不同挂载点)
|
||||
if partition.device in seen_devices:
|
||||
continue
|
||||
try:
|
||||
usage = psutil.disk_usage(partition.mountpoint)
|
||||
if usage:
|
||||
seen_devices.add(partition.device)
|
||||
disk_list.append(
|
||||
DiskInfo(
|
||||
dir=partition.mountpoint,
|
||||
device=partition.device,
|
||||
type=partition.fstype,
|
||||
total=fmt_bytes(usage.total),
|
||||
used=fmt_bytes(usage.used),
|
||||
free=fmt_bytes(usage.free),
|
||||
usage=f'{usage.percent:.2f}%',
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
# 服务信息
|
||||
process = psutil.Process(os.getpid())
|
||||
proc_mem = process.memory_info()
|
||||
try:
|
||||
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
|
||||
start_time = timezone.from_datetime(create_time)
|
||||
except (psutil.NoSuchProcess, OSError):
|
||||
start_time = timezone.now()
|
||||
|
||||
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
|
||||
|
||||
service = ServiceInfo(
|
||||
name='Python3',
|
||||
version=platform.python_version(),
|
||||
home=sys.executable,
|
||||
startup=timezone.to_str(start_time),
|
||||
elapsed=elapsed,
|
||||
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
|
||||
mem_vms=fmt_bytes(proc_mem.vms),
|
||||
mem_rss=fmt_bytes(proc_mem.rss),
|
||||
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
|
||||
)
|
||||
|
||||
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
|
||||
|
||||
data = await run_in_threadpool(get_all_info)
|
||||
return response_base.success(data=data)
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
|
||||
from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router
|
||||
from backend.app.admin.api.v1.sys.dept import router as dept_router
|
||||
from backend.app.admin.api.v1.sys.file import router as file_router
|
||||
from backend.app.admin.api.v1.sys.menu import router as menu_router
|
||||
from backend.app.admin.api.v1.sys.plugin import router as plugin_router
|
||||
from backend.app.admin.api.v1.sys.role import router as role_router
|
||||
from backend.app.admin.api.v1.sys.user import router as user_router
|
||||
|
||||
router = APIRouter(prefix='/sys')
|
||||
|
||||
router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
|
||||
router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
|
||||
router.include_router(role_router, prefix='/roles', tags=['系统角色'])
|
||||
router.include_router(user_router, prefix='/users', tags=['系统用户'])
|
||||
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
|
||||
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
|
||||
router.include_router(file_router, prefix='/files', tags=['系统文件'])
|
||||
router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
|
||||
@@ -0,0 +1,119 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.data_rule import (
|
||||
CreateDataRuleParam,
|
||||
DeleteDataRuleParam,
|
||||
GetDataRuleColumnDetail,
|
||||
GetDataRuleDetail,
|
||||
GetDataRuleTemplateVariableDetail,
|
||||
UpdateDataRuleParam,
|
||||
)
|
||||
from backend.app.admin.service.data_rule_service import data_rule_service
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
|
||||
models = await data_rule_service.get_models()
|
||||
return response_base.success(data=models)
|
||||
|
||||
|
||||
@router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule_model_columns(
|
||||
model: Annotated[str, Path(description='模型名称')],
|
||||
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
|
||||
models = await data_rule_service.get_columns(model=model)
|
||||
return response_base.success(data=models)
|
||||
|
||||
|
||||
@router.get('/value-template-variables', summary='获取数据规则值可用模板变量', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule_value_template_variables() -> ResponseSchemaModel[list[GetDataRuleTemplateVariableDetail]]:
|
||||
variables = await data_rule_service.get_value_template_variables()
|
||||
return response_base.success(data=variables)
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
|
||||
async def get_all_data_rules(db: CurrentSession) -> ResponseSchemaModel[list[GetDataRuleDetail]]:
|
||||
data = await data_rule_service.get_all(db=db)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
|
||||
async def get_data_rule(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='数据规则 ID')],
|
||||
) -> ResponseSchemaModel[GetDataRuleDetail]:
|
||||
data = await data_rule_service.get(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有数据规则',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_data_rules_paginated(
|
||||
db: CurrentSession,
|
||||
name: Annotated[str | None, Query(description='规则名称')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
|
||||
page_data = await data_rule_service.get_list(db=db, name=name)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_data_rule(db: CurrentSessionTransaction, obj: CreateDataRuleParam) -> ResponseModel:
|
||||
await data_rule_service.create(db=db, obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_rule(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='数据规则 ID')],
|
||||
obj: UpdateDataRuleParam,
|
||||
) -> ResponseModel:
|
||||
count = await data_rule_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除数据规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:rule:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_data_rules(db: CurrentSessionTransaction, obj: DeleteDataRuleParam) -> ResponseModel:
|
||||
count = await data_rule_service.delete(db=db, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,128 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
CreateDataScopeParam,
|
||||
DeleteDataScopeParam,
|
||||
GetDataScopeDetail,
|
||||
GetDataScopeWithRelationDetail,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.app.admin.service.data_scope_service import data_scope_service
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
|
||||
async def get_all_data_scope(db: CurrentSession) -> ResponseSchemaModel[list[GetDataScopeDetail]]:
|
||||
data = await data_scope_service.get_all(db=db)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
|
||||
async def get_data_scope(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
) -> ResponseSchemaModel[GetDataScopeDetail]:
|
||||
data = await data_scope_service.get(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
|
||||
async def get_data_scope_rules(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
|
||||
data = await data_scope_service.get_rules(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有数据范围',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_data_scopes_paginated(
|
||||
db: CurrentSession,
|
||||
name: Annotated[str | None, Query(description='范围名称')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
|
||||
page_data = await data_scope_service.get_list(db=db, name=name, status=status)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_data_scope(db: CurrentSessionTransaction, obj: CreateDataScopeParam) -> ResponseModel:
|
||||
await data_scope_service.create(db=db, obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_scope(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
obj: UpdateDataScopeParam,
|
||||
) -> ResponseModel:
|
||||
count = await data_scope_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/rules',
|
||||
summary='更新数据范围规则',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:rule:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_data_scope_rules(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='数据范围 ID')],
|
||||
rule_ids: UpdateDataScopeRuleParam,
|
||||
) -> ResponseModel:
|
||||
count = await data_scope_service.update_data_scope_rule(db=db, pk=pk, rule_ids=rule_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('data:scope:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_data_scopes(db: CurrentSessionTransaction, obj: DeleteDataScopeParam) -> ResponseModel:
|
||||
count = await data_scope_service.delete(db=db, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
|
||||
from backend.app.admin.service.dept_service import dept_service
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import DataPermissionFilter, RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
|
||||
async def get_dept(
|
||||
db: CurrentSession, pk: Annotated[int, Path(description='部门 ID')]
|
||||
) -> ResponseSchemaModel[GetDeptDetail]:
|
||||
data = await dept_service.get(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
|
||||
async def get_dept_tree(
|
||||
db: CurrentSession,
|
||||
data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
|
||||
name: Annotated[str | None, Query(description='部门名称')] = None,
|
||||
leader: Annotated[str | None, Query(description='部门负责人')] = None,
|
||||
phone: Annotated[str | None, Query(description='联系电话')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[list[GetDeptTree]]:
|
||||
dept = await dept_service.get_tree(
|
||||
db=db, data_filter=data_filter, name=name, leader=leader, phone=phone, status=status
|
||||
)
|
||||
return response_base.success(data=dept)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_dept(db: CurrentSessionTransaction, obj: CreateDeptParam) -> ResponseModel:
|
||||
await dept_service.create(db=db, obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_dept(
|
||||
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam
|
||||
) -> ResponseModel:
|
||||
count = await dept_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='删除部门',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:dept:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_dept(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
|
||||
count = await dept_service.delete(db=db, pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
|
||||
from backend.common.dataclasses import UploadUrl
|
||||
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.utils.file_ops import upload_file, upload_file_verify
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
'/upload',
|
||||
summary='本地文件上传',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:file:upload')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
|
||||
upload_file_verify(file)
|
||||
filename = await upload_file(file)
|
||||
return response_base.success(data={'url': f'/static/upload/{filename}'})
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
|
||||
from backend.app.admin.service.menu_service import menu_service
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='已适配 vben admin v5', dependencies=[DependsJwtAuth])
|
||||
async def get_user_sidebar(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
|
||||
menu = await menu_service.get_sidebar(db=db, request=request)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
|
||||
async def get_menu(
|
||||
db: CurrentSession, pk: Annotated[int, Path(description='菜单 ID')]
|
||||
) -> ResponseSchemaModel[GetMenuDetail]:
|
||||
data = await menu_service.get(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('', summary='获取菜单树', dependencies=[DependsJwtAuth])
|
||||
async def get_menu_tree(
|
||||
db: CurrentSession,
|
||||
title: Annotated[str | None, Query(description='菜单标题')] = None,
|
||||
status: Annotated[int | None, Query(description='状体')] = None,
|
||||
) -> ResponseSchemaModel[list[GetMenuTree]]:
|
||||
menu = await menu_service.get_tree(db=db, title=title, status=status)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_menu(db: CurrentSessionTransaction, obj: CreateMenuParam) -> ResponseModel:
|
||||
await menu_service.create(db=db, obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_menu(
|
||||
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam
|
||||
) -> ResponseModel:
|
||||
count = await menu_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{pk}',
|
||||
summary='删除菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:menu:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_menu(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
|
||||
count = await menu_service.delete(db=db, pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, File, Path, UploadFile
|
||||
from fastapi.params import Query
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from backend.app.admin.service.plugin_service import plugin_service
|
||||
from backend.common.enums import PluginType
|
||||
from backend.common.response.response_code import CustomResponse
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsSuperUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('', summary='获取所有插件', dependencies=[DependsSuperUser])
|
||||
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
|
||||
plugins = await plugin_service.get_all()
|
||||
return response_base.success(data=plugins)
|
||||
|
||||
|
||||
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsSuperUser])
|
||||
async def plugin_changed() -> ResponseSchemaModel[bool]:
|
||||
plugins = await plugin_service.changed()
|
||||
return response_base.success(data=bool(plugins))
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='安装插件',
|
||||
description='使用插件 zip 压缩包或 git 仓库地址进行安装(仅开发环境)',
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def install_plugin(
|
||||
type: Annotated[PluginType, Query(description='插件类型')],
|
||||
file: Annotated[UploadFile | None, File()] = None,
|
||||
repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None,
|
||||
) -> ResponseModel:
|
||||
plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
|
||||
return response_base.success(
|
||||
res=CustomResponse(
|
||||
code=200,
|
||||
msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{plugin}',
|
||||
summary='卸载插件',
|
||||
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录(仅开发环境)',
|
||||
dependencies=[DependsSuperUser],
|
||||
)
|
||||
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
|
||||
await plugin_service.uninstall(plugin=plugin)
|
||||
return response_base.success(
|
||||
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务'),
|
||||
)
|
||||
|
||||
|
||||
@router.put('/{plugin}/status', summary='更新插件状态', dependencies=[DependsSuperUser])
|
||||
async def update_plugin_status(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
|
||||
await plugin_service.update_status(plugin=plugin)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.get('/{plugin}', summary='下载插件', dependencies=[DependsSuperUser])
|
||||
async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
|
||||
bio = await plugin_service.build(plugin=plugin)
|
||||
return StreamingResponse(
|
||||
bio,
|
||||
media_type='application/x-zip-compressed',
|
||||
headers={'Content-Disposition': f'attachment; filename={plugin}.zip'},
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from backend.app.admin.schema.menu import GetMenuTree
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleParam,
|
||||
DeleteRoleParam,
|
||||
GetRoleDetail,
|
||||
GetRoleWithRelationDetail,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.app.admin.service.role_service import role_service
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
|
||||
async def get_all_roles(db: CurrentSession) -> ResponseSchemaModel[list[GetRoleDetail]]:
|
||||
data = await role_service.get_all(db=db)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
|
||||
async def get_role_menu_tree(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='角色 ID')],
|
||||
) -> ResponseSchemaModel[list[GetMenuTree] | None]:
|
||||
menu = await role_service.get_menu_tree(db=db, pk=pk)
|
||||
return response_base.success(data=menu)
|
||||
|
||||
|
||||
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
|
||||
async def get_role_scopes(
|
||||
db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
|
||||
) -> ResponseSchemaModel[list[int]]:
|
||||
rule = await role_service.get_scopes(db=db, pk=pk)
|
||||
return response_base.success(data=rule)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
|
||||
async def get_role(
|
||||
db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
|
||||
) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
|
||||
data = await role_service.get(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有角色',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_roles_paginated(
|
||||
db: CurrentSession,
|
||||
name: Annotated[str | None, Query(description='角色名称')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
|
||||
page_data = await role_service.get_list(db=db, name=name, status=status)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
summary='创建角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:add')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def create_role(db: CurrentSessionTransaction, obj: CreateRoleParam) -> ResponseModel:
|
||||
await role_service.create(db=db, obj=obj)
|
||||
return response_base.success()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}',
|
||||
summary='更新角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role(
|
||||
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam
|
||||
) -> ResponseModel:
|
||||
count = await role_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/menus',
|
||||
summary='更新角色菜单',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:menu:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role_menus(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='角色 ID')],
|
||||
menu_ids: UpdateRoleMenuParam,
|
||||
) -> ResponseModel:
|
||||
count = await role_service.update_role_menu(db=db, pk=pk, menu_ids=menu_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put(
|
||||
'/{pk}/scopes',
|
||||
summary='更新角色数据范围',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:scope:edit')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def update_role_scopes(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='角色 ID')],
|
||||
scope_ids: UpdateRoleScopeParam,
|
||||
) -> ResponseModel:
|
||||
count = await role_service.update_role_scope(db=db, pk=pk, scope_ids=scope_ids)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
'',
|
||||
summary='批量删除角色',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:role:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_roles(db: CurrentSessionTransaction, obj: DeleteRoleParam) -> ResponseModel:
|
||||
count = await role_service.delete(db=db, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,172 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Request
|
||||
|
||||
from backend.app.admin.schema.role import GetRoleDetail
|
||||
from backend.app.admin.schema.user import (
|
||||
AddUserParam,
|
||||
GetCurrentUserInfoWithRelationDetail,
|
||||
GetUserInfoWithRelationDetail,
|
||||
ResetPasswordParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.service.user_service import user_service
|
||||
from backend.common.enums import UserPermissionType
|
||||
from backend.common.pagination import DependsPagination, PageData
|
||||
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
|
||||
from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser
|
||||
from backend.common.security.permission import RequestPermission
|
||||
from backend.common.security.rbac import DependsRBAC
|
||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
|
||||
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
|
||||
data = request.user.model_dump()
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}', summary='获取用户信息', dependencies=[DependsJwtAuth])
|
||||
async def get_userinfo(
|
||||
db: CurrentSession,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
|
||||
data = await user_service.get_userinfo(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
|
||||
async def get_user_roles(
|
||||
db: CurrentSession, pk: Annotated[int, Path(description='用户 ID')]
|
||||
) -> ResponseSchemaModel[list[GetRoleDetail]]:
|
||||
data = await user_service.get_roles(db=db, pk=pk)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
summary='分页获取所有用户',
|
||||
dependencies=[
|
||||
DependsJwtAuth,
|
||||
DependsPagination,
|
||||
],
|
||||
)
|
||||
async def get_users_paginated(
|
||||
db: CurrentSession,
|
||||
dept: Annotated[int | None, Query(description='部门 ID')] = None,
|
||||
username: Annotated[str | None, Query(description='用户名')] = None,
|
||||
phone: Annotated[str | None, Query(description='手机号')] = None,
|
||||
status: Annotated[int | None, Query(description='状态')] = None,
|
||||
) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
|
||||
page_data = await user_service.get_list(db=db, dept=dept, username=username, phone=phone, status=status)
|
||||
return response_base.success(data=page_data)
|
||||
|
||||
|
||||
@router.post('', summary='创建用户', dependencies=[DependsSuperUser])
|
||||
async def create_user(
|
||||
db: CurrentSessionTransaction, obj: AddUserParam
|
||||
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
|
||||
await user_service.create(db=db, obj=obj)
|
||||
data = await user_service.get_userinfo(db=db, username=obj.username)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsSuperUser])
|
||||
async def update_user(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
obj: UpdateUserParam,
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update(db=db, pk=pk, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsSuperUser])
|
||||
async def update_user_permission(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
type: Annotated[UserPermissionType, Query(description='权限类型')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_permission(db=db, request=request, pk=pk, type=type)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/me/password', summary='更新当前用户密码', dependencies=[DependsJwtAuth])
|
||||
async def update_user_password(
|
||||
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsSuperUser])
|
||||
async def reset_user_password(
|
||||
db: CurrentSessionTransaction,
|
||||
pk: Annotated[int, Path(description='用户 ID')],
|
||||
password: Annotated[str, Body(embed=True, description='新密码')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.reset_password(db=db, pk=pk, password=password)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth])
|
||||
async def update_user_nickname(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth])
|
||||
async def update_user_avatar(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth])
|
||||
async def update_user_email(
|
||||
db: CurrentSessionTransaction,
|
||||
request: Request,
|
||||
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
|
||||
email: Annotated[str, Body(embed=True, description='用户邮箱')],
|
||||
) -> ResponseModel:
|
||||
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
|
||||
|
||||
@router.delete(
|
||||
path='/{pk}',
|
||||
summary='删除用户',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('sys:user:del')),
|
||||
DependsRBAC,
|
||||
],
|
||||
)
|
||||
async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
|
||||
count = await user_service.delete(db=db, pk=pk)
|
||||
if count > 0:
|
||||
return response_base.success()
|
||||
return response_base.fail()
|
||||
@@ -0,0 +1,110 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import DataRule
|
||||
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDataRule(CRUDPlus[DataRule]):
|
||||
"""数据规则数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
|
||||
"""
|
||||
获取规则详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None) -> Select:
|
||||
"""
|
||||
获取规则列表查询表达式
|
||||
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
|
||||
"""
|
||||
通过名称获取规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
|
||||
"""
|
||||
获取所有规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataRule]:
|
||||
"""
|
||||
通过 ID 列表批量获取数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=pks, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
|
||||
"""
|
||||
创建规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建规则参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
|
||||
"""
|
||||
更新规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:param obj: 更新规则参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
|
||||
@@ -0,0 +1,167 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, and_, delete, insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import DataRule, DataScope, data_scope_rule
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
CreateDataScopeParam,
|
||||
CreateDataScopeRuleParam,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDataScope(CRUDPlus[DataScope]):
|
||||
"""数据范围数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, pk: int) -> DataScope | None:
|
||||
"""
|
||||
获取数据范围详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, pk, deleted=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
|
||||
"""
|
||||
通过名称获取数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 范围名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_join(self, db: AsyncSession, pk: int) -> Any:
|
||||
"""
|
||||
获取数据范围关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=pk,
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
|
||||
JoinConfig(
|
||||
model=DataRule,
|
||||
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
|
||||
"""
|
||||
获取所有数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, pks: list[int]) -> Sequence[DataScope]:
|
||||
"""
|
||||
通过 ID 列表批量获取数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=pks, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取数据范围列表查询表达式
|
||||
|
||||
:param name: 范围名称
|
||||
:param status: 范围状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
|
||||
"""
|
||||
创建数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建数据范围参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
|
||||
"""
|
||||
更新数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param obj: 更新数据范围参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, obj, id=pk, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param rule_ids: 数据规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
|
||||
await db.execute(data_scope_rule_stmt)
|
||||
|
||||
if rule_ids.rules:
|
||||
data_scope_rule_data = [
|
||||
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump()
|
||||
for rule_id in rule_ids.rules
|
||||
]
|
||||
data_scope_rule_stmt = insert(data_scope_rule)
|
||||
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
|
||||
|
||||
return len(rule_ids.rules)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=pks,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
|
||||
@@ -0,0 +1,143 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import Dept, User
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDDept(CRUDPlus[Dept]):
|
||||
"""部门数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
|
||||
"""
|
||||
获取部门详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, id=dept_id, deleted=0)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
|
||||
"""
|
||||
通过名称获取部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 部门名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
data_filter: ColumnElement[bool],
|
||||
name: str | None,
|
||||
leader: str | None,
|
||||
phone: str | None,
|
||||
status: int | None,
|
||||
) -> Sequence[Dept]:
|
||||
"""
|
||||
获取所有部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param data_filter: 请求用户
|
||||
:param name: 部门名称
|
||||
:param leader: 负责人
|
||||
:param phone: 联系电话
|
||||
:param status: 部门状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if leader is not None:
|
||||
filters['leader__like'] = f'%{leader}%'
|
||||
if phone is not None:
|
||||
filters['phone__startswith'] = phone
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_models_order(db, 'sort', 'asc', data_filter, **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
|
||||
"""
|
||||
创建部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建部门参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
|
||||
"""
|
||||
更新部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:param obj: 更新部门参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, obj, id=dept_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, dept_id: int) -> int:
|
||||
"""
|
||||
删除部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=dept_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
|
||||
"""
|
||||
获取部门及关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
result = await self.select_model(
|
||||
db,
|
||||
dept_id,
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(
|
||||
model=User,
|
||||
join_on=and_(User.dept_id == self.model.id, User.deleted == 0),
|
||||
fill_result=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
return select_join_serialize(result, relationships=['Dept-o2m-User'])
|
||||
|
||||
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
|
||||
"""
|
||||
获取子部门列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept_id: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=dept_id, deleted=0)
|
||||
|
||||
|
||||
dept_dao: CRUDDept = CRUDDept(Dept)
|
||||
@@ -0,0 +1,64 @@
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import LoginLog
|
||||
from backend.app.admin.schema.login_log import CreateLoginLogParam
|
||||
|
||||
|
||||
class CRUDLoginLog(CRUDPlus[LoginLog]):
|
||||
"""登录日志数据库操作类"""
|
||||
|
||||
async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取登录日志列表查询表达式
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 登录状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if username is not None:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
if ip is not None:
|
||||
filters['ip__like'] = f'%{ip}%'
|
||||
|
||||
return await self.select_order('created_time', 'desc', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
|
||||
"""
|
||||
创建登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建登录日志参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj, commit=True)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 登录日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(db: AsyncSession) -> None:
|
||||
"""
|
||||
删除所有日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
await db.execute(sa_delete(LoginLog))
|
||||
|
||||
|
||||
login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog)
|
||||
@@ -0,0 +1,132 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import Menu, role_menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDMenu(CRUDPlus[Menu]):
|
||||
"""菜单数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
|
||||
"""
|
||||
获取菜单详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, menu_id, deleted=0)
|
||||
|
||||
async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
|
||||
"""
|
||||
通过标题获取菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param title: 菜单标题
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, title=title, type__ne=2, deleted=0)
|
||||
|
||||
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
|
||||
"""
|
||||
获取菜单列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param title: 菜单标题
|
||||
:param status: 菜单状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if title is not None:
|
||||
filters['title__like'] = f'%{title}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_models_order(db, 'sort', 'asc', **filters)
|
||||
|
||||
async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
|
||||
"""
|
||||
获取用户的菜单侧边栏
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
filters = {'type__in': [0, 1, 3, 4], 'deleted': 0}
|
||||
|
||||
if menu_ids:
|
||||
filters['id__in'] = menu_ids
|
||||
|
||||
return await self.select_models_order(db, 'sort', 'asc', **filters)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, menu_ids: list[int]) -> Sequence[Menu]:
|
||||
"""
|
||||
通过 ID 列表批量获取菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=menu_ids, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||
"""
|
||||
创建菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建菜单参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
|
||||
"""
|
||||
更新菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:param obj: 更新菜单参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, obj, id=menu_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, menu_id: int) -> int:
|
||||
"""
|
||||
删除菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=menu_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
|
||||
"""
|
||||
获取子菜单列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_id: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, parent_id=menu_id, deleted=0)
|
||||
|
||||
|
||||
menu_dao: CRUDMenu = CRUDMenu(Menu)
|
||||
@@ -0,0 +1,74 @@
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model import OperaLog
|
||||
from backend.app.admin.schema.opera_log import CreateOperaLogParam
|
||||
|
||||
|
||||
class CRUDOperaLog(CRUDPlus[OperaLog]):
|
||||
"""操作日志数据库操作类"""
|
||||
|
||||
async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
|
||||
"""
|
||||
获取操作日志列表查询表达式
|
||||
|
||||
:param username: 用户名
|
||||
:param status: 操作状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
filters = {}
|
||||
|
||||
if username is not None:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if status is not None:
|
||||
filters['status__eq'] = status
|
||||
if ip is not None:
|
||||
filters['ip__like'] = f'%{ip}%'
|
||||
|
||||
return await self.select_order('created_time', 'desc', **filters)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
|
||||
"""
|
||||
创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 操作日志创建参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
|
||||
"""
|
||||
批量创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param objs: 操作日志创建参数列表
|
||||
:return:
|
||||
"""
|
||||
await self.create_models(db, objs)
|
||||
|
||||
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
|
||||
"""
|
||||
批量删除操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pks: 操作日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(db: AsyncSession) -> None:
|
||||
"""
|
||||
删除所有日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
await db.execute(sa_delete(OperaLog))
|
||||
|
||||
|
||||
opera_log_dao: CRUDOperaLog = CRUDOperaLog(OperaLog)
|
||||
@@ -0,0 +1,215 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, and_, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import DataScope, Menu, Role, role_data_scope, role_menu
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleMenuParam,
|
||||
CreateRoleParam,
|
||||
CreateRoleScopeParam,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDRole(CRUDPlus[Role]):
|
||||
"""角色数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, role_id: int) -> Role | None:
|
||||
"""
|
||||
获取角色详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, role_id, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
|
||||
"""
|
||||
获取角色菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
menu_stmt = (
|
||||
select(Menu)
|
||||
.join(role_menu, Menu.id == role_menu.c.menu_id)
|
||||
.where(role_menu.c.role_id == role_id, Menu.deleted == 0)
|
||||
)
|
||||
result = await db.execute(menu_stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_join(self, db: AsyncSession, role_id: int) -> Any:
|
||||
"""
|
||||
获取角色及关联数据
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
result = await self.select_models(
|
||||
db,
|
||||
id=role_id,
|
||||
deleted=0,
|
||||
join_conditions=[
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
|
||||
JoinConfig(
|
||||
model=Menu,
|
||||
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
|
||||
JoinConfig(
|
||||
model=DataScope,
|
||||
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return select_join_serialize(result, relationships=['Role-m2m-Menu', 'Role-m2m-DataScope:scopes'])
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
|
||||
"""
|
||||
获取所有角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, deleted=0)
|
||||
|
||||
async def get_all_by_ids(self, db: AsyncSession, role_ids: list[int]) -> Sequence[Role]:
|
||||
"""
|
||||
通过 ID 列表批量获取角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, id__in=role_ids, deleted=0)
|
||||
|
||||
async def get_select(self, name: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取角色列表查询表达式
|
||||
|
||||
:param name: 角色名称
|
||||
:param status: 角色状态
|
||||
:return:
|
||||
"""
|
||||
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if name is not None:
|
||||
filters['name__like'] = f'%{name}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order('id', **filters)
|
||||
|
||||
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
|
||||
"""
|
||||
通过名称获取角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 角色名称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, name=name, deleted=0)
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
|
||||
"""
|
||||
创建角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建角色参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
|
||||
"""
|
||||
更新角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:param obj: 更新角色参数
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, obj, id=role_id, deleted=0)
|
||||
|
||||
@staticmethod
|
||||
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
"""
|
||||
更新角色菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
|
||||
await db.execute(role_menu_stmt)
|
||||
|
||||
if menu_ids.menus:
|
||||
role_menu_data = [
|
||||
CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump() for menu_id in menu_ids.menus
|
||||
]
|
||||
role_menu_stmt = insert(role_menu)
|
||||
await db.execute(role_menu_stmt, role_menu_data)
|
||||
|
||||
return len(menu_ids.menus)
|
||||
|
||||
@staticmethod
|
||||
async def update_scopes(db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
"""
|
||||
更新角色数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_id: 角色 ID
|
||||
:param scope_ids: 权限范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
|
||||
await db.execute(role_scope_stmt)
|
||||
|
||||
if scope_ids.scopes:
|
||||
role_scope_data = [
|
||||
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump()
|
||||
for scope_id in scope_ids.scopes
|
||||
]
|
||||
role_scope_stmt = insert(role_data_scope)
|
||||
await db.execute(role_scope_stmt, role_scope_data)
|
||||
|
||||
return len(scope_ids.scopes)
|
||||
|
||||
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
|
||||
"""
|
||||
批量删除角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
allow_multiple=True,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id__in=role_ids,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
|
||||
role_dao: CRUDRole = CRUDRole(Role)
|
||||
@@ -0,0 +1,418 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sqlalchemy import Select, and_, delete, insert, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
|
||||
|
||||
from backend.app.admin.model import (
|
||||
DataRule,
|
||||
DataScope,
|
||||
Dept,
|
||||
Menu,
|
||||
Role,
|
||||
User,
|
||||
data_scope_rule,
|
||||
role_data_scope,
|
||||
role_menu,
|
||||
user_role,
|
||||
)
|
||||
from backend.app.admin.schema.user import (
|
||||
AddOAuth2UserParam,
|
||||
AddUserParam,
|
||||
AddUserRoleParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.utils.password_security import get_hash_password
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.plugin.core import check_plugin_installed
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class CRUDUser(CRUDPlus[User]):
|
||||
"""用户数据库操作类"""
|
||||
|
||||
async def get(self, db: AsyncSession, user_id: int) -> User | None:
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model(db, user_id, deleted=0)
|
||||
|
||||
async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
|
||||
"""
|
||||
通过用户名获取用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, username=username, deleted=0)
|
||||
|
||||
async def get_all_by_usernames(self, db: AsyncSession, usernames: list[str]) -> Sequence[User]:
|
||||
"""
|
||||
通过用户名列表批量获取用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param usernames: 用户名列表
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models(db, username__in=usernames, deleted=0)
|
||||
|
||||
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
|
||||
"""
|
||||
通过昵称获取用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, nickname=nickname, deleted=0)
|
||||
|
||||
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
||||
"""
|
||||
检查邮箱是否已被绑定
|
||||
|
||||
:param db: 数据库会话
|
||||
:param email: 电子邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.select_model_by_column(db, email=email, deleted=0)
|
||||
|
||||
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
|
||||
"""
|
||||
获取用户列表查询表达式
|
||||
|
||||
:param dept: 部门 ID
|
||||
:param username: 用户名
|
||||
:param phone: 电话号码
|
||||
:param status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if dept:
|
||||
filters['dept_id'] = dept
|
||||
if username:
|
||||
filters['username__like'] = f'%{username}%'
|
||||
if phone:
|
||||
filters['phone__like'] = f'%{phone}%'
|
||||
if status is not None:
|
||||
filters['status'] = status
|
||||
|
||||
return await self.select_order(
|
||||
'id',
|
||||
'desc',
|
||||
join_conditions=[
|
||||
JoinConfig(
|
||||
model=Dept,
|
||||
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(
|
||||
model=Role,
|
||||
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
|
||||
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
"""
|
||||
添加用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 添加用户参数
|
||||
:return:
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
obj.password = get_hash_password(obj.password, salt)
|
||||
|
||||
dict_obj = obj.model_dump(exclude={'roles'})
|
||||
dict_obj.update({'salt': salt})
|
||||
new_user = self.model(**dict_obj)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
if obj.roles:
|
||||
role_stmt = select(Role).where(Role.id.in_(obj.roles), Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
|
||||
user_role_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
|
||||
"""
|
||||
通过 OAuth2 添加用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 注册用户参数
|
||||
:return:
|
||||
"""
|
||||
dict_obj = obj.model_dump()
|
||||
dict_obj.update({'is_staff': True, 'salt': None})
|
||||
new_user = self.model(**dict_obj)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
role_stmt = select(Role).where(Role.status == StatusType.enable, Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
role = result.scalars().first() # 默认绑定第一个角色
|
||||
if role is None:
|
||||
raise errors.NotFoundError(msg='未找到可用角色,请联系系统管理员')
|
||||
|
||||
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param obj: 更新用户参数
|
||||
:return:
|
||||
"""
|
||||
role_ids = obj.roles
|
||||
del obj.roles
|
||||
|
||||
count = await self.update_model_by_column(db, obj, id=user_id, deleted=0)
|
||||
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
if role_ids:
|
||||
role_stmt = select(Role).where(Role.id.in_(role_ids), Role.deleted == 0)
|
||||
result = await db.execute(role_stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
|
||||
user_role_stmt = insert(user_role)
|
||||
await db.execute(user_role_stmt, user_role_data)
|
||||
|
||||
return count
|
||||
|
||||
async def update_login_time(self, db: AsyncSession, username: str) -> int:
|
||||
"""
|
||||
更新用户上次登录时间
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username, deleted=0)
|
||||
|
||||
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
更新用户上次密码变更时间
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(
|
||||
db, {'last_password_changed_time': timezone.now()}, id=user_id, deleted=0
|
||||
)
|
||||
|
||||
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新用户昵称
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'nickname': nickname}, id=user_id, deleted=0)
|
||||
|
||||
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
更新用户头像
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'avatar': avatar}, id=user_id, deleted=0)
|
||||
|
||||
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
|
||||
"""
|
||||
更新用户邮箱
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'email': email}, id=user_id, deleted=0)
|
||||
|
||||
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
重置用户密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param password: 新密码
|
||||
:return:
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
new_pwd = get_hash_password(password, salt)
|
||||
return await self.update_model_by_column(db, {'password': new_pwd, 'salt': salt}, flush=True, id=pk, deleted=0)
|
||||
|
||||
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
|
||||
"""
|
||||
设置用户超级管理员状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param is_super: 是否超级管理员
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'is_superuser': is_super}, id=user_id, deleted=0)
|
||||
|
||||
async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
|
||||
"""
|
||||
设置用户后台登录状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param is_staff: 是否可登录后台
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'is_staff': is_staff}, id=user_id, deleted=0)
|
||||
|
||||
async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
|
||||
"""
|
||||
设置用户状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'status': status}, id=user_id, deleted=0)
|
||||
|
||||
async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
|
||||
"""
|
||||
设置用户多端登录状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param multi_login: 是否允许多端登录
|
||||
:return:
|
||||
"""
|
||||
return await self.update_model_by_column(db, {'is_multi_login': multi_login}, id=user_id, deleted=0)
|
||||
|
||||
async def delete(self, db: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
if check_plugin_installed('oauth2'):
|
||||
try:
|
||||
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
|
||||
|
||||
await user_social_dao.delete_by_user_id(db, user_id)
|
||||
except ImportError:
|
||||
raise errors.ServerError(msg='OAuth2 插件用法导入失败,请联系系统管理员')
|
||||
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
return await self.delete_model_by_column(
|
||||
db,
|
||||
logical_deletion=True,
|
||||
deleted_flag_column='deleted',
|
||||
deleted_flag_value=self.model.id,
|
||||
deleted_at_column='deleted_time',
|
||||
deleted_at_factory=timezone.now(),
|
||||
id=user_id,
|
||||
deleted=0,
|
||||
)
|
||||
|
||||
async def get_join(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> Any | None:
|
||||
"""
|
||||
获取用户关联信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
filters = {'deleted': 0}
|
||||
|
||||
if user_id:
|
||||
filters['id'] = user_id
|
||||
if username:
|
||||
filters['username'] = username
|
||||
|
||||
result = await self.select_models(
|
||||
db,
|
||||
join_conditions=[
|
||||
JoinConfig(
|
||||
model=Dept,
|
||||
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
|
||||
JoinConfig(
|
||||
model=Role,
|
||||
join_on=and_(Role.id == user_role.c.role_id, Role.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
|
||||
JoinConfig(
|
||||
model=Menu,
|
||||
join_on=and_(Menu.id == role_menu.c.menu_id, Menu.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
|
||||
JoinConfig(
|
||||
model=DataScope,
|
||||
join_on=and_(DataScope.id == role_data_scope.c.data_scope_id, DataScope.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
|
||||
JoinConfig(
|
||||
model=DataRule,
|
||||
join_on=and_(DataRule.id == data_scope_rule.c.data_rule_id, DataRule.deleted == 0),
|
||||
fill_result=True,
|
||||
),
|
||||
],
|
||||
**filters,
|
||||
)
|
||||
|
||||
return select_join_serialize(
|
||||
result,
|
||||
relationships=[
|
||||
'User-m2o-Dept',
|
||||
'User-m2m-Role',
|
||||
'Role-m2m-Menu',
|
||||
'Role-m2m-DataScope:scopes',
|
||||
'DataScope-m2m-DataRule:rules',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
user_dao: CRUDUser = CRUDUser(User)
|
||||
@@ -0,0 +1,34 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model.user_password_history import UserPasswordHistory
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
|
||||
|
||||
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
|
||||
"""用户密码历史记录数据库操作类"""
|
||||
|
||||
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
|
||||
"""
|
||||
创建密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建密码历史记录参数
|
||||
:return:
|
||||
"""
|
||||
await self.create_model(db, obj)
|
||||
|
||||
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
|
||||
"""
|
||||
获取用户的密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
|
||||
|
||||
|
||||
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)
|
||||
@@ -0,0 +1,13 @@
|
||||
from backend.app.admin.model.data_rule import DataRule as DataRule
|
||||
from backend.app.admin.model.data_scope import DataScope as DataScope
|
||||
from backend.app.admin.model.dept import Dept as Dept
|
||||
from backend.app.admin.model.login_log import LoginLog as LoginLog
|
||||
from backend.app.admin.model.m2m import data_scope_rule as data_scope_rule
|
||||
from backend.app.admin.model.m2m import role_data_scope as role_data_scope
|
||||
from backend.app.admin.model.m2m import role_menu as role_menu
|
||||
from backend.app.admin.model.m2m import user_role as user_role
|
||||
from backend.app.admin.model.menu import Menu as Menu
|
||||
from backend.app.admin.model.opera_log import OperaLog as OperaLog
|
||||
from backend.app.admin.model.role import Role as Role
|
||||
from backend.app.admin.model.user import User as User
|
||||
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
|
||||
@@ -0,0 +1,25 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
|
||||
class DataRule(Base):
|
||||
"""数据规则表"""
|
||||
|
||||
__tablename__ = 'sys_data_rule'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_rule_name_deleted'),
|
||||
{'comment': '数据规则表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(512), comment='名称')
|
||||
model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
|
||||
column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
|
||||
operator: Mapped[int] = mapped_column(comment='运算符(0:and、1:or)')
|
||||
expression: Mapped[int] = mapped_column(
|
||||
comment='表达式(0:==、1:!=、2:>、3:>=、4:<、5:<=、6:in、7:not_in)',
|
||||
)
|
||||
value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
|
||||
@@ -0,0 +1,19 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
|
||||
class DataScope(Base):
|
||||
"""数据范围表"""
|
||||
|
||||
__tablename__ = 'sys_data_scope'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_data_scope_name_deleted'),
|
||||
{'comment': '数据范围表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, id_key
|
||||
|
||||
|
||||
class Dept(Base):
|
||||
"""部门表"""
|
||||
|
||||
__tablename__ = 'sys_dept'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_dept_name_deleted'),
|
||||
{'comment': '部门表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
|
||||
|
||||
# 父级部门
|
||||
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class LoginLog(DataClassBase):
|
||||
"""登录日志表"""
|
||||
|
||||
__tablename__ = 'sys_login_log'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
|
||||
username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
|
||||
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||
msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
|
||||
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone,
|
||||
init=False,
|
||||
default_factory=timezone.now,
|
||||
comment='创建时间',
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from backend.common.model import MappedBase
|
||||
|
||||
# 用户角色表
|
||||
user_role = sa.Table(
|
||||
'sys_user_role',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
)
|
||||
|
||||
# 角色菜单表
|
||||
role_menu = sa.Table(
|
||||
'sys_role_menu',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
|
||||
sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
|
||||
)
|
||||
|
||||
# 角色数据范围表
|
||||
role_data_scope = sa.Table(
|
||||
'sys_role_data_scope',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
|
||||
sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
|
||||
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||
)
|
||||
|
||||
# 数据范围规则表
|
||||
data_scope_rule = sa.Table(
|
||||
'sys_data_scope_rule',
|
||||
MappedBase.metadata,
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
|
||||
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
|
||||
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 ID'),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, UniversalText, id_key
|
||||
|
||||
|
||||
class Menu(Base):
|
||||
"""菜单表"""
|
||||
|
||||
__tablename__ = 'sys_menu'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
|
||||
name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
|
||||
path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
|
||||
sort: Mapped[int] = mapped_column(default=0, comment='排序')
|
||||
icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
|
||||
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||
component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
|
||||
perms: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='权限标识')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
|
||||
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
|
||||
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
|
||||
link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
|
||||
# 父级菜单
|
||||
parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class OperaLog(DataClassBase):
|
||||
"""操作日志表"""
|
||||
|
||||
__tablename__ = 'sys_opera_log'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
|
||||
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
|
||||
method: Mapped[str] = mapped_column(sa.String(32), comment='请求方法')
|
||||
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
|
||||
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP 地址')
|
||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='用户代理')
|
||||
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||
args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
|
||||
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
|
||||
code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
|
||||
msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
|
||||
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms)')
|
||||
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, UniversalText, id_key
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
|
||||
__tablename__ = 'sys_role'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('name', 'deleted', name='uk_sys_role_name_deleted'),
|
||||
{'comment': '角色表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(32), comment='角色名称')
|
||||
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
|
||||
is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
|
||||
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import Base, TimeZone, id_key
|
||||
from backend.database.db import uuid4_str
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
|
||||
__tablename__ = 'sys_user'
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('username', 'deleted', name='uk_sys_user_username_deleted'),
|
||||
sa.UniqueConstraint('email', 'deleted', name='uk_sys_user_email_deleted'),
|
||||
{'comment': '用户表'},
|
||||
)
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
|
||||
username: Mapped[str] = mapped_column(sa.String(64), index=True, comment='用户名')
|
||||
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
|
||||
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
|
||||
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(256), comment='加密盐')
|
||||
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, index=True, comment='邮箱')
|
||||
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
|
||||
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
|
||||
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
|
||||
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
|
||||
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
|
||||
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
|
||||
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
|
||||
last_login_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
|
||||
)
|
||||
last_password_changed_time: Mapped[datetime | None] = mapped_column(
|
||||
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
|
||||
)
|
||||
|
||||
# 逻辑外键
|
||||
dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import DataClassBase, TimeZone, id_key
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class UserPasswordHistory(DataClassBase):
|
||||
"""用户密码历史记录表"""
|
||||
|
||||
__tablename__ = 'sys_user_password_history'
|
||||
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
user_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='用户 ID')
|
||||
password: Mapped[str] = mapped_column(sa.String(256), comment='历史密码')
|
||||
created_time: Mapped[datetime] = mapped_column(
|
||||
TimeZone,
|
||||
init=False,
|
||||
default_factory=timezone.now,
|
||||
comment='创建时间',
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class GetCaptchaDetail(SchemaBase):
|
||||
"""验证码详情"""
|
||||
|
||||
is_enabled: bool = Field(description='是否启用')
|
||||
expire_seconds: int = Field(description='过期秒数')
|
||||
uuid: str = Field(description='图片唯一标识')
|
||||
image: str = Field(description='图片内容')
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class DataRuleSchemaBase(SchemaBase):
|
||||
"""数据规则基础模型"""
|
||||
|
||||
name: str = Field(description='规则名称')
|
||||
model: str = Field(description='模型名称')
|
||||
column: str = Field(description='字段名称')
|
||||
operator: RoleDataRuleOperatorType = Field(description='操作符(AND/OR)')
|
||||
expression: RoleDataRuleExpressionType = Field(description='表达式类型')
|
||||
value: str = Field(description='规则值')
|
||||
|
||||
|
||||
class CreateDataRuleParam(DataRuleSchemaBase):
|
||||
"""创建数据规则参数"""
|
||||
|
||||
|
||||
class UpdateDataRuleParam(DataRuleSchemaBase):
|
||||
"""更新数据规则参数"""
|
||||
|
||||
|
||||
class DeleteDataRuleParam(SchemaBase):
|
||||
"""删除数据规则参数"""
|
||||
|
||||
pks: list[int] = Field(description='规则 ID 列表')
|
||||
|
||||
|
||||
class GetDataRuleDetail(DataRuleSchemaBase):
|
||||
"""数据规则详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, frozen=True)
|
||||
|
||||
id: int = Field(description='规则 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetDataRuleColumnDetail(SchemaBase):
|
||||
"""数据规则可用模型字段详情"""
|
||||
|
||||
key: str = Field(description='字段名')
|
||||
comment: str | None = Field(description='字段评论')
|
||||
|
||||
|
||||
class GetDataRuleTemplateVariableDetail(SchemaBase):
|
||||
"""数据规则可用模板变量详情"""
|
||||
|
||||
key: str = Field(description='变量标识')
|
||||
comment: str = Field(description='变量描述')
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.app.admin.schema.data_rule import GetDataRuleDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class DataScopeSchemaBase(SchemaBase):
|
||||
"""数据范围基础模型"""
|
||||
|
||||
name: str = Field(description='名称')
|
||||
status: StatusType = Field(description='状态')
|
||||
|
||||
|
||||
class CreateDataScopeParam(DataScopeSchemaBase):
|
||||
"""创建数据范围参数"""
|
||||
|
||||
|
||||
class UpdateDataScopeParam(DataScopeSchemaBase):
|
||||
"""更新数据范围参数"""
|
||||
|
||||
|
||||
class CreateDataScopeRuleParam(SchemaBase):
|
||||
"""创建数据范围规则参数"""
|
||||
|
||||
data_scope_id: int = Field(description='数据范围 ID')
|
||||
data_rule_id: int = Field(description='数据规则 ID')
|
||||
|
||||
|
||||
class UpdateDataScopeRuleParam(SchemaBase):
|
||||
"""更新数据范围规则参数"""
|
||||
|
||||
rules: list[int] = Field(description='数据规则 ID 列表')
|
||||
|
||||
|
||||
class DeleteDataScopeParam(SchemaBase):
|
||||
"""删除数据范围参数"""
|
||||
|
||||
pks: list[int] = Field(description='数据范围 ID 列表')
|
||||
|
||||
|
||||
class GetDataScopeDetail(DataScopeSchemaBase):
|
||||
"""数据范围详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='数据范围 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
|
||||
"""数据范围关联详情"""
|
||||
|
||||
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
|
||||
|
||||
|
||||
class DeptSchemaBase(SchemaBase):
|
||||
"""部门基础模型"""
|
||||
|
||||
name: str = Field(description='部门名称')
|
||||
parent_id: int | None = Field(None, description='部门父级 ID')
|
||||
sort: int = Field(0, ge=0, description='排序')
|
||||
leader: str | None = Field(None, description='负责人')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='联系电话')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
status: StatusType = Field(description='状态')
|
||||
|
||||
|
||||
class CreateDeptParam(DeptSchemaBase):
|
||||
"""创建部门参数"""
|
||||
|
||||
|
||||
class UpdateDeptParam(DeptSchemaBase):
|
||||
"""更新部门参数"""
|
||||
|
||||
|
||||
class GetDeptDetail(DeptSchemaBase):
|
||||
"""部门详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='部门 ID')
|
||||
deleted: int = Field(description='是否已删除(0:否;id:是)')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
deleted_time: datetime | None = Field(None, description='删除时间')
|
||||
|
||||
|
||||
class GetDeptTree(GetDeptDetail):
|
||||
"""获取部门树"""
|
||||
|
||||
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class LoginLogSchemaBase(SchemaBase):
|
||||
"""登录日志基础模型"""
|
||||
|
||||
user_uuid: str = Field(description='用户 UUID')
|
||||
username: str = Field(description='用户名')
|
||||
status: int = Field(description='登录状态')
|
||||
ip: str = Field(description='IP 地址')
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str | None = Field(description='用户代理')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
device: str | None = Field(None, description='设备')
|
||||
msg: str = Field(description='消息')
|
||||
login_time: datetime = Field(description='登录时间')
|
||||
|
||||
|
||||
class CreateLoginLogParam(LoginLogSchemaBase):
|
||||
"""创建登录日志参数"""
|
||||
|
||||
|
||||
class UpdateLoginLogParam(LoginLogSchemaBase):
|
||||
"""更新登录日志参数"""
|
||||
|
||||
|
||||
class DeleteLoginLogParam(SchemaBase):
|
||||
"""删除登录日志参数"""
|
||||
|
||||
pks: list[int] = Field(description='登录日志 ID 列表')
|
||||
|
||||
|
||||
class GetLoginLogDetail(LoginLogSchemaBase):
|
||||
"""登录日志详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='日志 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import MenuType, StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class MenuSchemaBase(SchemaBase):
|
||||
"""菜单基础模型"""
|
||||
|
||||
title: str = Field(description='菜单标题')
|
||||
name: str = Field(description='菜单名称')
|
||||
path: str | None = Field(None, description='路由地址')
|
||||
parent_id: int | None = Field(None, description='菜单父级 ID')
|
||||
sort: int = Field(0, ge=0, description='排序')
|
||||
icon: str | None = Field(None, description='图标')
|
||||
type: MenuType = Field(description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
|
||||
component: str | None = Field(None, description='组件路径')
|
||||
perms: str | None = Field(None, description='权限标识')
|
||||
status: StatusType = Field(description='状态')
|
||||
display: StatusType = Field(description='是否显示')
|
||||
cache: StatusType = Field(description='是否缓存')
|
||||
link: str | None = Field(None, description='外链地址')
|
||||
remark: str | None = Field(None, description='备注')
|
||||
|
||||
|
||||
class CreateMenuParam(MenuSchemaBase):
|
||||
"""创建菜单参数"""
|
||||
|
||||
|
||||
class UpdateMenuParam(MenuSchemaBase):
|
||||
"""更新菜单参数"""
|
||||
|
||||
|
||||
class GetMenuDetail(MenuSchemaBase):
|
||||
"""菜单详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='菜单 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetMenuTree(GetMenuDetail):
|
||||
"""获取菜单树"""
|
||||
|
||||
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
|
||||
@@ -0,0 +1,102 @@
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class CpuInfo(SchemaBase):
|
||||
"""CPU 信息"""
|
||||
|
||||
physical_num: int = Field(description='物理核心数')
|
||||
logical_num: int = Field(description='逻辑核心数')
|
||||
max_freq: float = Field(description='最大频率(MHz)')
|
||||
min_freq: float = Field(description='最小频率(MHz)')
|
||||
current_freq: float = Field(description='当前频率(MHz)')
|
||||
usage: float = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class MemInfo(SchemaBase):
|
||||
"""内存信息"""
|
||||
|
||||
total: float = Field(description='总容量(GB)')
|
||||
used: float = Field(description='已使用(GB)')
|
||||
free: float = Field(description='可用(GB)')
|
||||
usage: float = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class SysInfo(SchemaBase):
|
||||
"""系统信息"""
|
||||
|
||||
name: str = Field(description='主机名')
|
||||
os: str = Field(description='操作系统')
|
||||
ip: str = Field(description='IP 地址')
|
||||
arch: str = Field(description='系统架构')
|
||||
|
||||
|
||||
class DiskInfo(SchemaBase):
|
||||
"""磁盘信息"""
|
||||
|
||||
dir: str = Field(description='挂载点')
|
||||
device: str = Field(description='设备名称')
|
||||
type: str = Field(description='文件系统类型')
|
||||
total: str = Field(description='总容量')
|
||||
used: str = Field(description='已使用')
|
||||
free: str = Field(description='可用')
|
||||
usage: str = Field(description='使用率(%)')
|
||||
|
||||
|
||||
class ServiceInfo(SchemaBase):
|
||||
"""服务进程信息"""
|
||||
|
||||
name: str = Field(description='服务名称')
|
||||
version: str = Field(description='版本')
|
||||
home: str = Field(description='安装路径')
|
||||
startup: str = Field(description='启动时间')
|
||||
elapsed: str = Field(description='运行时长')
|
||||
cpu_usage: str = Field(description='CPU 使用率')
|
||||
mem_vms: str = Field(description='虚拟内存')
|
||||
mem_rss: str = Field(description='物理内存')
|
||||
mem_free: str = Field(description='可用内存')
|
||||
|
||||
|
||||
class ServerMonitorInfo(SchemaBase):
|
||||
"""服务器监控信息"""
|
||||
|
||||
cpu: CpuInfo = Field(description='CPU 信息')
|
||||
mem: MemInfo = Field(description='内存信息')
|
||||
sys: SysInfo = Field(description='系统信息')
|
||||
disk: list[DiskInfo] = Field(description='磁盘信息')
|
||||
service: ServiceInfo = Field(description='服务信息')
|
||||
|
||||
|
||||
class RedisServerInfo(SchemaBase):
|
||||
"""Redis 服务器信息"""
|
||||
|
||||
redis_version: str = Field(description='版本号')
|
||||
redis_mode: str = Field(description='运行模式')
|
||||
role: str = Field(description='节点角色')
|
||||
tcp_port: str = Field(description='监听端口')
|
||||
uptime: str = Field(description='运行时长')
|
||||
connected_clients: str = Field(description='已连接客户端数')
|
||||
blocked_clients: str = Field(description='阻塞客户端数')
|
||||
used_memory_human: str = Field(description='已使用内存')
|
||||
used_memory_rss_human: str = Field(description='RSS 内存')
|
||||
maxmemory_human: str = Field(description='最大内存限制')
|
||||
mem_fragmentation_ratio: str = Field(description='内存碎片率')
|
||||
instantaneous_ops_per_sec: str = Field(description='每秒操作数')
|
||||
total_commands_processed: str = Field(description='命令处理总数')
|
||||
rejected_connections: str = Field(description='拒绝连接数')
|
||||
keys_num: str = Field(description='键总数')
|
||||
|
||||
|
||||
class RedisCommandStat(SchemaBase):
|
||||
"""Redis 命令统计"""
|
||||
|
||||
name: str = Field(description='命令名称')
|
||||
value: str = Field(description='调用次数')
|
||||
|
||||
|
||||
class RedisMonitorInfo(SchemaBase):
|
||||
"""Redis 监控信息"""
|
||||
|
||||
info: RedisServerInfo = Field(description='服务器信息')
|
||||
stats: list[RedisCommandStat] = Field(description='命令统计')
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class OperaLogSchemaBase(SchemaBase):
|
||||
"""操作日志基础模型"""
|
||||
|
||||
trace_id: str = Field(description='追踪 ID')
|
||||
username: str | None = Field(None, description='用户名')
|
||||
method: str = Field(description='请求方法')
|
||||
title: str = Field(description='操作标题')
|
||||
path: str = Field(description='请求路径')
|
||||
ip: str = Field(description='IP 地址')
|
||||
country: str | None = Field(None, description='国家')
|
||||
region: str | None = Field(None, description='地区')
|
||||
city: str | None = Field(None, description='城市')
|
||||
user_agent: str | None = Field(description='用户代理')
|
||||
os: str | None = Field(None, description='操作系统')
|
||||
browser: str | None = Field(None, description='浏览器')
|
||||
device: str | None = Field(None, description='设备')
|
||||
args: dict[str, Any] | None = Field(None, description='请求参数')
|
||||
status: StatusType = Field(description='状态')
|
||||
code: str = Field(description='状态码')
|
||||
msg: str | None = Field(None, description='消息')
|
||||
cost_time: float = Field(description='耗时')
|
||||
opera_time: datetime = Field(description='操作时间')
|
||||
|
||||
|
||||
class CreateOperaLogParam(OperaLogSchemaBase):
|
||||
"""创建操作日志参数"""
|
||||
|
||||
|
||||
class UpdateOperaLogParam(OperaLogSchemaBase):
|
||||
"""更新操作日志参数"""
|
||||
|
||||
|
||||
class DeleteOperaLogParam(SchemaBase):
|
||||
"""删除操作日志参数"""
|
||||
|
||||
pks: list[int] = Field(description='操作日志 ID 列表')
|
||||
|
||||
|
||||
class GetOperaLogDetail(OperaLogSchemaBase):
|
||||
"""操作日志详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='日志 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from backend.app.admin.schema.data_scope import GetDataScopeWithRelationDetail
|
||||
from backend.app.admin.schema.menu import GetMenuDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class RoleSchemaBase(SchemaBase):
|
||||
"""角色基础模型"""
|
||||
|
||||
name: str = Field(description='角色名称')
|
||||
status: StatusType = Field(description='状态')
|
||||
is_filter_scopes: bool = Field(True, description='过滤数据权限')
|
||||
remark: str | None = Field(None, description='备注')
|
||||
|
||||
|
||||
class CreateRoleParam(RoleSchemaBase):
|
||||
"""创建角色参数"""
|
||||
|
||||
|
||||
class UpdateRoleParam(RoleSchemaBase):
|
||||
"""更新角色参数"""
|
||||
|
||||
|
||||
class DeleteRoleParam(SchemaBase):
|
||||
"""删除角色参数"""
|
||||
|
||||
pks: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class CreateRoleMenuParam(SchemaBase):
|
||||
"""创建角色菜单参数"""
|
||||
|
||||
role_id: int = Field(description='角色 ID')
|
||||
menu_id: int = Field(description='菜单 ID')
|
||||
|
||||
|
||||
class UpdateRoleMenuParam(SchemaBase):
|
||||
"""更新角色菜单参数"""
|
||||
|
||||
menus: list[int] = Field(description='菜单 ID 列表')
|
||||
|
||||
|
||||
class CreateRoleScopeParam(SchemaBase):
|
||||
"""创建角色数据范围参数"""
|
||||
|
||||
role_id: int = Field(description='角色 ID')
|
||||
data_scope_id: int = Field(description='数据范围 ID')
|
||||
|
||||
|
||||
class UpdateRoleScopeParam(SchemaBase):
|
||||
"""更新角色数据范围参数"""
|
||||
|
||||
scopes: list[int] = Field(description='数据范围 ID 列表')
|
||||
|
||||
|
||||
class GetRoleDetail(RoleSchemaBase):
|
||||
"""角色详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description='角色 ID')
|
||||
created_time: datetime = Field(description='创建时间')
|
||||
updated_time: datetime | None = Field(None, description='更新时间')
|
||||
|
||||
|
||||
class GetRoleWithRelationDetail(GetRoleDetail):
|
||||
"""角色关联详情"""
|
||||
|
||||
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
|
||||
scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from backend.app.admin.schema.user import GetUserInfoDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class GetSwaggerToken(SchemaBase):
|
||||
"""Swagger 认证令牌"""
|
||||
|
||||
access_token: str = Field(description='访问令牌')
|
||||
token_type: str = Field('Bearer', description='令牌类型')
|
||||
user: GetUserInfoDetail = Field(description='用户信息')
|
||||
|
||||
|
||||
class AccessTokenBase(SchemaBase):
|
||||
"""访问令牌基础模型"""
|
||||
|
||||
access_token: str = Field(description='访问令牌')
|
||||
access_token_expire_time: datetime = Field(description='令牌过期时间')
|
||||
session_uuid: str = Field(description='会话 UUID')
|
||||
|
||||
|
||||
class GetNewToken(AccessTokenBase):
|
||||
"""获取新令牌"""
|
||||
|
||||
|
||||
class GetLoginToken(AccessTokenBase):
|
||||
"""获取登录令牌"""
|
||||
|
||||
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
|
||||
user: GetUserInfoDetail = Field(description='用户信息')
|
||||
|
||||
|
||||
class GetTokenDetail(SchemaBase):
|
||||
"""令牌详情"""
|
||||
|
||||
id: int = Field(description='用户 ID')
|
||||
session_uuid: str = Field(description='会话 UUID')
|
||||
username: str = Field(description='用户名')
|
||||
nickname: str = Field(description='昵称')
|
||||
ip: str = Field(description='IP 地址')
|
||||
os: str = Field(description='操作系统')
|
||||
browser: str = Field(description='浏览器')
|
||||
device: str = Field(description='设备')
|
||||
status: StatusType = Field(description='状态')
|
||||
last_login_time: str = Field(description='最后登录时间')
|
||||
expire_time: datetime = Field(description='过期时间')
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from backend.app.admin.schema.dept import GetDeptDetail
|
||||
from backend.app.admin.schema.role import GetRoleWithRelationDetail
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
|
||||
|
||||
|
||||
class AuthSchemaBase(SchemaBase):
|
||||
"""用户认证基础模型"""
|
||||
|
||||
username: str = Field(description='用户名')
|
||||
password: str = Field(description='密码')
|
||||
|
||||
|
||||
class AuthLoginParam(AuthSchemaBase):
|
||||
"""用户登录参数"""
|
||||
|
||||
uuid: str | None = Field(None, description='验证码 UUID')
|
||||
captcha: str | None = Field(None, description='验证码')
|
||||
|
||||
|
||||
class AddUserParam(AuthSchemaBase):
|
||||
"""添加用户参数"""
|
||||
|
||||
nickname: str | None = Field(None, description='昵称')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='手机号码')
|
||||
dept_id: int = Field(description='部门 ID')
|
||||
roles: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class AddUserRoleParam(SchemaBase):
|
||||
"""添加用户角色"""
|
||||
|
||||
user_id: int = Field(description='用户 ID')
|
||||
role_id: int = Field(description='角色 ID')
|
||||
|
||||
|
||||
class AddOAuth2UserParam(AuthSchemaBase):
|
||||
"""添加 OAuth2 用户参数"""
|
||||
|
||||
password: str | None = Field(None, description='密码')
|
||||
nickname: str | None = Field(None, description='昵称')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
|
||||
|
||||
|
||||
class ResetPasswordParam(SchemaBase):
|
||||
"""重置密码参数"""
|
||||
|
||||
old_password: str = Field(description='旧密码')
|
||||
new_password: str = Field(description='新密码')
|
||||
confirm_password: str = Field(description='确认密码')
|
||||
|
||||
|
||||
class UserInfoSchemaBase(SchemaBase):
|
||||
"""用户信息基础模型"""
|
||||
|
||||
dept_id: int | None = Field(None, description='部门 ID')
|
||||
username: str = Field(description='用户名')
|
||||
nickname: str = Field(description='昵称')
|
||||
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
|
||||
email: CustomEmailStr | None = Field(None, description='邮箱')
|
||||
phone: CustomPhoneNumber | None = Field(None, description='手机号')
|
||||
|
||||
|
||||
class UpdateUserParam(UserInfoSchemaBase):
|
||||
"""更新用户参数"""
|
||||
|
||||
roles: list[int] = Field(description='角色 ID 列表')
|
||||
|
||||
|
||||
class GetUserInfoDetail(UserInfoSchemaBase):
|
||||
"""用户信息详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept_id: int | None = Field(None, description='部门 ID')
|
||||
id: int = Field(description='用户 ID')
|
||||
uuid: str = Field(description='用户 UUID')
|
||||
status: StatusType = Field(description='状态')
|
||||
is_superuser: bool = Field(description='是否超级管理员')
|
||||
is_staff: bool = Field(description='是否管理员')
|
||||
is_multi_login: bool = Field(description='是否允许多端登录')
|
||||
join_time: datetime = Field(description='加入时间')
|
||||
last_login_time: datetime | None = Field(None, description='最后登录时间')
|
||||
|
||||
|
||||
class GetUserInfoWithRelationDetail(GetUserInfoDetail):
|
||||
"""用户信息关联详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept: GetDeptDetail | None = Field(None, description='部门信息')
|
||||
roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
|
||||
|
||||
|
||||
class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
|
||||
"""当前用户信息关联详情"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dept: str | None = Field(None, description='部门名称')
|
||||
roles: list[str] = Field(description='角色名称列表')
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def handel(cls, data: Any) -> Self:
|
||||
"""处理部门和角色数据"""
|
||||
dept = data['dept']
|
||||
if dept:
|
||||
data['dept'] = dept['name']
|
||||
roles = data['roles']
|
||||
if roles:
|
||||
data['roles'] = [role['name'] for role in roles]
|
||||
return data
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class UserPasswordHistorySchemaBase(SchemaBase):
|
||||
"""用户历史密码记录基础模型"""
|
||||
|
||||
user_id: int = Field(description='用户 ID')
|
||||
password: str = Field(description='历史密码')
|
||||
|
||||
|
||||
class CreateUserPasswordHistoryParam(UserPasswordHistorySchemaBase):
|
||||
"""创建用户历史密码记录"""
|
||||
@@ -0,0 +1,272 @@
|
||||
from fastapi import Request, Response
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTask, BackgroundTasks
|
||||
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.crud.crud_user import user_dao
|
||||
from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.token import GetLoginToken, GetNewToken
|
||||
from backend.app.admin.schema.user import AuthLoginParam
|
||||
from backend.app.admin.service.login_log_service import login_log_service
|
||||
from backend.app.admin.service.user_password_history_service import password_security_service
|
||||
from backend.app.admin.utils.password_security import password_verify
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import LoginLogStatusType, StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.i18n import t
|
||||
from backend.common.log import log
|
||||
from backend.common.response.response_code import CustomErrorCode
|
||||
from backend.common.security.jwt import jwt_decode
|
||||
from backend.common.security.token import (
|
||||
create_access_token,
|
||||
create_new_token,
|
||||
create_refresh_token,
|
||||
get_token,
|
||||
get_user_sessions,
|
||||
revoke_token,
|
||||
)
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import uuid4_str
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_login_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""认证服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def user_verify(*, db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
|
||||
"""
|
||||
验证用户名和密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:param password: 密码
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_by_username(db, username)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户名或密码有误')
|
||||
await password_security_service.check_status(user.id, user.status)
|
||||
if user.password is None or not password_verify(password, user.password):
|
||||
await password_security_service.handle_login_failure(db, user.id)
|
||||
raise errors.AuthorizationError(msg='用户名或密码有误')
|
||||
|
||||
days_remaining = await password_security_service.check_password_expiry_status(
|
||||
db, user.last_password_changed_time
|
||||
)
|
||||
await password_security_service.handle_login_success(user.id)
|
||||
|
||||
return user, days_remaining
|
||||
|
||||
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
|
||||
"""
|
||||
Swagger 文档登录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 登录凭证
|
||||
:return:
|
||||
"""
|
||||
user, _ = await self.user_verify(db=db, username=obj.username, password=obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
swagger=True,
|
||||
)
|
||||
return access_token_data.access_token, user
|
||||
|
||||
async def login(
|
||||
self,
|
||||
*,
|
||||
db: AsyncSession,
|
||||
response: Response,
|
||||
obj: AuthLoginParam,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> GetLoginToken:
|
||||
"""
|
||||
用户登录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param response: 响应对象
|
||||
:param obj: 登录参数
|
||||
:param background_tasks: 后台任务
|
||||
:return:
|
||||
"""
|
||||
user = None
|
||||
try:
|
||||
await load_login_config(db)
|
||||
if settings.LOGIN_CAPTCHA_ENABLED:
|
||||
if not obj.uuid or not obj.captcha:
|
||||
raise errors.RequestError(msg=t('error.captcha.invalid'))
|
||||
captcha_key = f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}'
|
||||
captcha_code = await redis_client.get(captcha_key)
|
||||
if not captcha_code:
|
||||
raise errors.RequestError(msg=t('error.captcha.expired'))
|
||||
if captcha_code.lower() != obj.captcha.lower():
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
await redis_client.delete(captcha_key)
|
||||
|
||||
user, days_remaining = await self.user_verify(db=db, username=obj.username, password=obj.password)
|
||||
await user_dao.update_login_time(db, obj.username)
|
||||
await db.refresh(user)
|
||||
access_token_data = await create_access_token(
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=ctx.ip,
|
||||
os=ctx.os,
|
||||
browser=ctx.browser,
|
||||
device=ctx.device,
|
||||
)
|
||||
refresh_token_data = await create_refresh_token(
|
||||
access_token_data.session_uuid,
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=refresh_token_data.refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
except errors.NotFoundError as e:
|
||||
log.error('登陆错误: 用户名不存在')
|
||||
raise errors.NotFoundError(msg=e.msg)
|
||||
except (errors.RequestError, errors.CustomError) as e:
|
||||
if not user:
|
||||
log.error(f'登陆错误: {e.msg}')
|
||||
task = BackgroundTask(
|
||||
login_log_service.create,
|
||||
user_uuid=user.uuid if user else uuid4_str(),
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
status=LoginLogStatusType.fail.value,
|
||||
msg=e.msg,
|
||||
)
|
||||
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
|
||||
except Exception as e:
|
||||
log.error(f'登陆错误: {e}')
|
||||
raise
|
||||
else:
|
||||
background_tasks.add_task(
|
||||
login_log_service.create,
|
||||
user_uuid=user.uuid,
|
||||
username=obj.username,
|
||||
login_time=timezone.now(),
|
||||
status=LoginLogStatusType.success.value,
|
||||
msg=t('success.login.success'),
|
||||
)
|
||||
data = GetLoginToken(
|
||||
access_token=access_token_data.access_token,
|
||||
access_token_expire_time=access_token_data.access_token_expire_time,
|
||||
session_uuid=access_token_data.session_uuid,
|
||||
password_expire_days_remaining=days_remaining,
|
||||
user=user, # type: ignore
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def get_codes(*, db: AsyncSession, request: Request) -> list[str]:
|
||||
"""
|
||||
获取用户权限码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
codes = set()
|
||||
if request.user.is_superuser:
|
||||
menus = await menu_dao.get_all(db, None, None)
|
||||
for menu in menus:
|
||||
if menu.status == StatusType.enable and menu.perms:
|
||||
codes.update(menu.perms.split(','))
|
||||
else:
|
||||
roles = [role for role in request.user.roles if role.status == StatusType.enable]
|
||||
if roles:
|
||||
for role in roles:
|
||||
for menu in role.menus:
|
||||
if menu.status == StatusType.enable and menu.perms:
|
||||
codes.update(menu.perms.split(','))
|
||||
|
||||
return list(codes)
|
||||
|
||||
@staticmethod
|
||||
async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
|
||||
"""
|
||||
刷新令牌
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param response: FastAPI 响应对象
|
||||
:return:
|
||||
"""
|
||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
if not refresh_token:
|
||||
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
|
||||
|
||||
token_payload = jwt_decode(refresh_token)
|
||||
user = await user_dao.get(db, token_payload.user_id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
if not user.is_multi_login and await get_user_sessions(user.id) - {token_payload.session_uuid}:
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
token_payload.session_uuid,
|
||||
user.id,
|
||||
multi_login=user.is_multi_login,
|
||||
# extra info
|
||||
username=user.username,
|
||||
nickname=user.nickname,
|
||||
last_login_time=timezone.to_str(user.last_login_time),
|
||||
ip=ctx.ip,
|
||||
os=ctx.os,
|
||||
browser=ctx.browser,
|
||||
device_type=ctx.device,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=new_token.new_refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
|
||||
data = GetNewToken(
|
||||
access_token=new_token.new_access_token,
|
||||
access_token_expire_time=new_token.new_access_token_expire_time,
|
||||
session_uuid=new_token.session_uuid,
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def logout(*, request: Request, response: Response) -> None:
|
||||
"""
|
||||
用户登出
|
||||
|
||||
:param request: FastAPI 请求对象
|
||||
:param response: FastAPI 响应对象
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
except errors.TokenError:
|
||||
return
|
||||
finally:
|
||||
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
|
||||
await revoke_token(token_payload.user_id, token_payload.session_uuid)
|
||||
|
||||
|
||||
auth_service: AuthService = AuthService()
|
||||
@@ -0,0 +1,156 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
||||
from backend.app.admin.model import DataRule
|
||||
from backend.app.admin.schema.data_rule import (
|
||||
CreateDataRuleParam,
|
||||
DeleteDataRuleParam,
|
||||
GetDataRuleColumnDetail,
|
||||
GetDataRuleTemplateVariableDetail,
|
||||
UpdateDataRuleParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.common.security.permission import get_data_permission_models
|
||||
from backend.core.conf import settings
|
||||
|
||||
|
||||
class DataRuleService:
|
||||
"""数据规则服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, db: AsyncSession, pk: int) -> DataRule:
|
||||
"""
|
||||
获取数据规则详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_rule = await data_rule_dao.get(db, pk)
|
||||
if not data_rule:
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
return data_rule
|
||||
|
||||
@staticmethod
|
||||
async def get_models() -> list[str]:
|
||||
"""获取所有数据规则可用模型"""
|
||||
model_template_variables = [var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES]
|
||||
models = [
|
||||
m for m in list(get_data_permission_models().keys()) if m not in settings.DATA_PERMISSION_MODEL_EXCLUDE
|
||||
]
|
||||
return model_template_variables + models
|
||||
|
||||
@staticmethod
|
||||
async def get_value_template_variables() -> list[GetDataRuleTemplateVariableDetail]:
|
||||
"""获取所有数据规则值可用模板变量"""
|
||||
return [GetDataRuleTemplateVariableDetail(**var) for var in settings.DATA_PERMISSION_TEMPLATE_VARIABLES]
|
||||
|
||||
@staticmethod
|
||||
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
|
||||
"""
|
||||
获取数据规则可用模型的字段列表
|
||||
|
||||
:param model: 模型名称
|
||||
:return:
|
||||
"""
|
||||
column_template_variables = [
|
||||
GetDataRuleColumnDetail(key=var['key'], comment=var['comment'])
|
||||
for var in settings.DATA_PERMISSION_COLUMN_TEMPLATE_VARIABLES
|
||||
]
|
||||
|
||||
model_template_variable_keys = {var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES}
|
||||
if model in model_template_variable_keys:
|
||||
return column_template_variables
|
||||
|
||||
available_models = get_data_permission_models()
|
||||
if model not in available_models:
|
||||
raise errors.NotFoundError(msg='数据规则可用模型不存在')
|
||||
model_ins = available_models[model]
|
||||
|
||||
table = model_ins if isinstance(model_ins, Table) else model_ins.__table__
|
||||
model_columns = [
|
||||
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
|
||||
for column in table.columns
|
||||
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
|
||||
]
|
||||
return model_columns + column_template_variables
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, name: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
获取数据规则列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 规则名称
|
||||
:return:
|
||||
"""
|
||||
data_rule_select = await data_rule_dao.get_select(name=name)
|
||||
return await paging_data(db, data_rule_select)
|
||||
|
||||
@staticmethod
|
||||
async def get_all(*, db: AsyncSession) -> Sequence[DataRule]:
|
||||
"""
|
||||
获取所有数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_rules = await data_rule_dao.get_all(db)
|
||||
return data_rules
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateDataRuleParam) -> None:
|
||||
"""
|
||||
创建数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 规则创建参数
|
||||
:return:
|
||||
"""
|
||||
data_rule = await data_rule_dao.get_by_name(db, obj.name)
|
||||
if data_rule:
|
||||
raise errors.ConflictError(msg='数据规则已存在')
|
||||
await data_rule_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
|
||||
"""
|
||||
更新数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 规则 ID
|
||||
:param obj: 规则更新参数
|
||||
:return:
|
||||
"""
|
||||
data_rule = await data_rule_dao.get(db, pk)
|
||||
if not data_rule:
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='数据规则已存在')
|
||||
count = await data_rule_dao.update(db, pk, obj)
|
||||
await user_cache_manager.clear_by_data_rule_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, obj: DeleteDataRuleParam) -> int:
|
||||
"""
|
||||
批量删除数据规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
count = await data_rule_dao.delete(db, obj.pks)
|
||||
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
data_rule_service: DataRuleService = DataRuleService()
|
||||
@@ -0,0 +1,146 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_data_rule import data_rule_dao
|
||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
||||
from backend.app.admin.model import DataScope
|
||||
from backend.app.admin.schema.data_scope import (
|
||||
CreateDataScopeParam,
|
||||
DeleteDataScopeParam,
|
||||
UpdateDataScopeParam,
|
||||
UpdateDataScopeRuleParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
|
||||
|
||||
class DataScopeService:
|
||||
"""数据范围服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, db: AsyncSession, pk: int) -> DataScope:
|
||||
"""
|
||||
获取数据范围详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
return data_scope
|
||||
|
||||
@staticmethod
|
||||
async def get_all(*, db: AsyncSession) -> Sequence[DataScope]:
|
||||
"""
|
||||
获取所有数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_scopes = await data_scope_dao.get_all(db)
|
||||
return data_scopes
|
||||
|
||||
@staticmethod
|
||||
async def get_rules(*, db: AsyncSession, pk: int) -> DataScope:
|
||||
"""
|
||||
获取数据范围规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
data_scope = await data_scope_dao.get_join(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
return data_scope
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
|
||||
"""
|
||||
获取数据范围列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 范围名称
|
||||
:param status: 范围状态
|
||||
:return:
|
||||
"""
|
||||
data_scope_select = await data_scope_dao.get_select(name, status)
|
||||
return await paging_data(db, data_scope_select)
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateDataScopeParam) -> None:
|
||||
"""
|
||||
创建数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 数据范围参数
|
||||
:return:
|
||||
"""
|
||||
data_scope = await data_scope_dao.get_by_name(db, obj.name)
|
||||
if data_scope:
|
||||
raise errors.ConflictError(msg='数据范围已存在')
|
||||
await data_scope_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
|
||||
"""
|
||||
更新数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param obj: 数据范围更新参数
|
||||
:return:
|
||||
"""
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='数据范围已存在')
|
||||
count = await data_scope_dao.update(db, pk, obj)
|
||||
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_data_scope_rule(*, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
|
||||
"""
|
||||
更新数据范围规则
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 范围 ID
|
||||
:param rule_ids: 规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
data_scope = await data_scope_dao.get(db, pk)
|
||||
if not data_scope:
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
if rule_ids.rules:
|
||||
rules = await data_rule_dao.get_all_by_ids(db, list(set(rule_ids.rules)))
|
||||
if {rule.id for rule in rules} != set(rule_ids.rules):
|
||||
raise errors.NotFoundError(msg='数据规则不存在')
|
||||
count = await data_scope_dao.update_rules(db, pk, rule_ids)
|
||||
await user_cache_manager.clear_by_data_scope_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, obj: DeleteDataScopeParam) -> int:
|
||||
"""
|
||||
批量删除数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
count = await data_scope_dao.delete(db, obj.pks)
|
||||
await user_cache_manager.clear_by_data_scope_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
data_scope_service: DataScopeService = DataScopeService()
|
||||
@@ -0,0 +1,119 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_dept import dept_dao
|
||||
from backend.app.admin.model import Dept
|
||||
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
|
||||
from backend.common.exception import errors
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
class DeptService:
|
||||
"""部门服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, db: AsyncSession, pk: int) -> Dept:
|
||||
"""
|
||||
获取部门详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
dept = await dept_dao.get(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
return dept
|
||||
|
||||
@staticmethod
|
||||
async def get_tree(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
data_filter: ColumnElement[bool],
|
||||
name: str | None,
|
||||
leader: str | None,
|
||||
phone: str | None,
|
||||
status: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取部门树形结构
|
||||
|
||||
:param db: 数据库会话
|
||||
:param data_filter: 请求用户
|
||||
:param name: 部门名称
|
||||
:param leader: 部门负责人
|
||||
:param phone: 联系电话
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
|
||||
tree_data = get_tree_data(dept_select)
|
||||
return tree_data
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateDeptParam) -> None:
|
||||
"""
|
||||
创建部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 部门创建参数
|
||||
:return:
|
||||
"""
|
||||
dept = await dept_dao.get_by_name(db, obj.name)
|
||||
if dept:
|
||||
raise errors.ConflictError(msg='部门名称已存在')
|
||||
if obj.parent_id is not None:
|
||||
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||
if not parent_dept:
|
||||
raise errors.NotFoundError(msg='父级部门不存在')
|
||||
await dept_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateDeptParam) -> int:
|
||||
"""
|
||||
更新部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 部门 ID
|
||||
:param obj: 部门更新参数
|
||||
:return:
|
||||
"""
|
||||
dept = await dept_dao.get(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if dept.name != obj.name and await dept_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='部门名称已存在')
|
||||
if obj.parent_id:
|
||||
parent_dept = await dept_dao.get(db, obj.parent_id)
|
||||
if not parent_dept:
|
||||
raise errors.NotFoundError(msg='父级部门不存在')
|
||||
if obj.parent_id == dept.id:
|
||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||
count = await dept_dao.update(db, pk, obj)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, pk: int) -> int:
|
||||
"""
|
||||
删除部门
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 部门 ID
|
||||
:return:
|
||||
"""
|
||||
dept = await dept_dao.get_join(db, pk)
|
||||
if not dept:
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if dept.users:
|
||||
raise errors.ConflictError(msg='部门下存在用户,无法删除')
|
||||
children = await dept_dao.get_children(db, pk)
|
||||
if children:
|
||||
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
|
||||
count = await dept_dao.delete(db, pk)
|
||||
return count
|
||||
|
||||
|
||||
dept_service: DeptService = DeptService()
|
||||
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_login_log import login_log_dao
|
||||
from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
|
||||
from backend.common.context import ctx
|
||||
from backend.common.log import log
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.database.db import async_db_session
|
||||
|
||||
|
||||
class LoginLogService:
|
||||
"""登录日志服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
获取登录日志列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:param status: 状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
log_select = await login_log_dao.get_select(username=username, status=status, ip=ip)
|
||||
return await paging_data(db, log_select)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
*,
|
||||
user_uuid: str,
|
||||
username: str,
|
||||
login_time: datetime,
|
||||
status: int,
|
||||
msg: str,
|
||||
) -> None:
|
||||
"""
|
||||
创建登录日志
|
||||
|
||||
:param user_uuid: 用户 UUID
|
||||
:param username: 用户名
|
||||
:param login_time: 登录时间
|
||||
:param status: 状态
|
||||
:param msg: 消息
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
obj = CreateLoginLogParam(
|
||||
user_uuid=user_uuid,
|
||||
username=username,
|
||||
status=status,
|
||||
ip=ctx.ip,
|
||||
country=ctx.country,
|
||||
region=ctx.region,
|
||||
city=ctx.city,
|
||||
user_agent=ctx.user_agent,
|
||||
browser=ctx.browser,
|
||||
os=ctx.os,
|
||||
device=ctx.device,
|
||||
msg=msg,
|
||||
login_time=login_time,
|
||||
)
|
||||
# 为后台任务创建独立数据库会话
|
||||
async with async_db_session.begin() as db:
|
||||
await login_log_dao.create(db, obj)
|
||||
except Exception as e:
|
||||
log.error(f'登录日志创建失败: {e}')
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, obj: DeleteLoginLogParam) -> int:
|
||||
"""
|
||||
批量删除登录日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
count = await login_log_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(*, db: AsyncSession) -> None:
|
||||
"""清空所有登录日志"""
|
||||
await login_log_dao.delete_all(db)
|
||||
|
||||
|
||||
login_log_service: LoginLogService = LoginLogService()
|
||||
@@ -0,0 +1,137 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.model import Menu
|
||||
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.enums import StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
|
||||
|
||||
|
||||
class MenuService:
|
||||
"""菜单服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, db: AsyncSession, pk: int) -> Menu:
|
||||
"""
|
||||
获取菜单详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
menu = await menu_dao.get(db, menu_id=pk)
|
||||
if not menu:
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
return menu
|
||||
|
||||
@staticmethod
|
||||
async def get_tree(*, db: AsyncSession, title: str | None, status: int | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取菜单树形结构
|
||||
|
||||
:param db: 数据库会话
|
||||
:param title: 菜单标题
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
|
||||
menu_data = await menu_dao.get_all(db, title=title, status=status)
|
||||
menu_tree = get_tree_data(menu_data)
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
async def get_sidebar(*, db: AsyncSession, request: Request) -> list[dict[str, Any] | None]:
|
||||
"""
|
||||
获取用户的菜单侧边栏
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:return:
|
||||
"""
|
||||
menu_data = None
|
||||
if request.user.is_superuser:
|
||||
menu_data = await menu_dao.get_sidebar(db, None)
|
||||
else:
|
||||
roles = [role for role in request.user.roles if role.status == StatusType.enable]
|
||||
menu_ids = set()
|
||||
if roles:
|
||||
for role in roles:
|
||||
menu_ids.update(menu.id for menu in role.menus)
|
||||
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
|
||||
|
||||
if menu_data:
|
||||
return get_vben5_tree_data(menu_data)
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
|
||||
"""
|
||||
创建菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 菜单创建参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
title = await menu_dao.get_by_title(db, obj.title)
|
||||
if title:
|
||||
raise errors.ConflictError(msg='菜单标题已存在')
|
||||
if obj.parent_id:
|
||||
parent_menu = await menu_dao.get(db, obj.parent_id)
|
||||
if not parent_menu:
|
||||
raise errors.NotFoundError(msg='父级菜单不存在')
|
||||
await menu_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateMenuParam) -> int:
|
||||
"""
|
||||
更新菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 菜单 ID
|
||||
:param obj: 菜单更新参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
menu = await menu_dao.get(db, pk)
|
||||
if not menu:
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
if menu.title != obj.title and await menu_dao.get_by_title(db, obj.title):
|
||||
raise errors.ConflictError(msg='菜单标题已存在')
|
||||
if obj.parent_id:
|
||||
parent_menu = await menu_dao.get(db, obj.parent_id)
|
||||
if not parent_menu:
|
||||
raise errors.NotFoundError(msg='父级菜单不存在')
|
||||
if obj.parent_id == menu.id:
|
||||
raise errors.ForbiddenError(msg='禁止关联自身为父级')
|
||||
count = await menu_dao.update(db, pk, obj)
|
||||
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, pk: int) -> int:
|
||||
"""
|
||||
删除菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 菜单 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
children = await menu_dao.get_children(db, pk)
|
||||
if children:
|
||||
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
|
||||
count = await menu_dao.delete(db, pk)
|
||||
if count:
|
||||
await user_cache_manager.clear_by_menu_id(db, [pk])
|
||||
return count
|
||||
|
||||
|
||||
menu_service: MenuService = MenuService()
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_opera_log import opera_log_dao
|
||||
from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
|
||||
from backend.common.pagination import paging_data
|
||||
|
||||
|
||||
class OperaLogService:
|
||||
"""操作日志服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
|
||||
"""
|
||||
获取操作日志列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param username: 用户名
|
||||
:param status: 状态
|
||||
:param ip: IP 地址
|
||||
:return:
|
||||
"""
|
||||
log_select = await opera_log_dao.get_select(username=username, status=status, ip=ip)
|
||||
return await paging_data(db, log_select)
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateOperaLogParam) -> None:
|
||||
"""
|
||||
创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 操作日志创建参数
|
||||
:return:
|
||||
"""
|
||||
await opera_log_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def bulk_create(*, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
|
||||
"""
|
||||
批量创建操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param objs: 操作日志创建参数列表
|
||||
:return:
|
||||
"""
|
||||
await opera_log_dao.bulk_create(db, objs)
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, obj: DeleteOperaLogParam) -> int:
|
||||
"""
|
||||
批量删除操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 日志 ID 列表
|
||||
:return:
|
||||
"""
|
||||
count = await opera_log_dao.delete(db, obj.pks)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete_all(*, db: AsyncSession) -> None:
|
||||
"""
|
||||
清空所有操作日志
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
await opera_log_dao.delete_all(db)
|
||||
|
||||
|
||||
opera_log_service: OperaLogService = OperaLogService()
|
||||
@@ -0,0 +1,135 @@
|
||||
import io
|
||||
import json
|
||||
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
|
||||
from fastapi import UploadFile
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.enums import PluginType, StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.database.redis import redis_client
|
||||
from backend.plugin.core import get_required_plugins
|
||||
from backend.plugin.installer import install_git_plugin, install_zip_plugin, remove_plugin, zip_plugin
|
||||
from backend.plugin.requirements import uninstall_requirements_async
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class PluginService:
|
||||
"""插件服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
"""获取所有插件"""
|
||||
|
||||
changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
|
||||
keys = [key for key in await redis_client.get_by_prefix(settings.PLUGIN_REDIS_PREFIX) if key != changed_key]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
result = []
|
||||
plugin_infos = await redis_client.mget(*keys)
|
||||
for info in plugin_infos:
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
plugin_info = json.loads(info)
|
||||
if isinstance(plugin_info, dict):
|
||||
result.append(plugin_info)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def changed() -> str | None:
|
||||
"""检查插件是否发生变更"""
|
||||
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
|
||||
|
||||
@staticmethod
|
||||
async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None) -> str:
|
||||
"""
|
||||
安装插件
|
||||
|
||||
:param type: 插件类型
|
||||
:param file: 插件 zip 压缩包
|
||||
:param repo_url: git 仓库地址
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.RequestError(msg='禁止在非开发环境下安装插件')
|
||||
if type == PluginType.zip:
|
||||
if not file:
|
||||
raise errors.RequestError(msg='ZIP 压缩包不能为空')
|
||||
return await install_zip_plugin(file)
|
||||
if not repo_url:
|
||||
raise errors.RequestError(msg='Git 仓库地址不能为空')
|
||||
return await install_git_plugin(repo_url)
|
||||
|
||||
@staticmethod
|
||||
async def uninstall(*, plugin: str) -> None:
|
||||
"""
|
||||
卸载插件
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
|
||||
if plugin in get_required_plugins():
|
||||
raise errors.RequestError(msg=f'插件 {plugin} 为必需插件,禁止卸载')
|
||||
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
|
||||
if not await plugin_dir.exists():
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
await uninstall_requirements_async(plugin)
|
||||
backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip'
|
||||
await run_in_threadpool(zip_plugin, plugin_dir, backup_file)
|
||||
await run_in_threadpool(remove_plugin, plugin_dir)
|
||||
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def update_status(*, plugin: str) -> None:
|
||||
"""
|
||||
更新插件状态
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
|
||||
plugin_info = await redis_client.get(plugin_key)
|
||||
if not plugin_info:
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
plugin_info = json.loads(plugin_info)
|
||||
|
||||
# 更新持久缓存状态
|
||||
new_status = (
|
||||
str(StatusType.enable.value)
|
||||
if plugin_info['plugin']['enable'] == str(StatusType.disable.value)
|
||||
else str(StatusType.disable.value)
|
||||
)
|
||||
plugin_info['plugin']['enable'] = new_status
|
||||
await redis_client.set(plugin_key, json.dumps(plugin_info, ensure_ascii=False))
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def build(*, plugin: str) -> io.BytesIO:
|
||||
"""
|
||||
打包插件为 zip 压缩包
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
|
||||
if not await plugin_dir.exists():
|
||||
raise errors.NotFoundError(msg='插件不存在')
|
||||
|
||||
bio = io.BytesIO()
|
||||
await run_in_threadpool(zip_plugin, plugin_dir, bio)
|
||||
bio.seek(0)
|
||||
return bio
|
||||
|
||||
|
||||
plugin_service: PluginService = PluginService()
|
||||
@@ -0,0 +1,193 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_data_scope import data_scope_dao
|
||||
from backend.app.admin.crud.crud_menu import menu_dao
|
||||
from backend.app.admin.crud.crud_role import role_dao
|
||||
from backend.app.admin.model import Role
|
||||
from backend.app.admin.schema.role import (
|
||||
CreateRoleParam,
|
||||
DeleteRoleParam,
|
||||
UpdateRoleMenuParam,
|
||||
UpdateRoleParam,
|
||||
UpdateRoleScopeParam,
|
||||
)
|
||||
from backend.app.admin.utils.cache import user_cache_manager
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.utils.build_tree import get_tree_data
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""角色服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get(*, db: AsyncSession, pk: int) -> Role:
|
||||
"""
|
||||
获取角色详情
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_join(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def get_all(*, db: AsyncSession) -> Sequence[Role]:
|
||||
"""
|
||||
获取所有角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
|
||||
roles = await role_dao.get_all(db)
|
||||
return roles
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
|
||||
"""
|
||||
获取角色列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param name: 角色名称
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
role_select = await role_dao.get_select(name=name, status=status)
|
||||
return await paging_data(db, role_select)
|
||||
|
||||
@staticmethod
|
||||
async def get_menu_tree(*, db: AsyncSession, pk: int) -> list[dict[str, Any] | None]:
|
||||
"""
|
||||
获取角色的菜单树形结构
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 角色 ID
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
menus = await role_dao.get_menus(db, pk)
|
||||
menu_tree = get_tree_data(menus) if menus else []
|
||||
return menu_tree
|
||||
|
||||
@staticmethod
|
||||
async def get_scopes(*, db: AsyncSession, pk: int) -> list[int]:
|
||||
"""
|
||||
获取角色数据范围列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk:
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_join(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
scope_ids = [scope.id for scope in role.scopes]
|
||||
return scope_ids
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: CreateRoleParam) -> None:
|
||||
"""
|
||||
创建角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 角色创建参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get_by_name(db, obj.name)
|
||||
if role:
|
||||
raise errors.ConflictError(msg='角色已存在')
|
||||
await role_dao.create(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateRoleParam) -> int:
|
||||
"""
|
||||
更新角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 角色 ID
|
||||
:param obj: 角色更新参数
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
|
||||
raise errors.ConflictError(msg='角色已存在')
|
||||
count = await role_dao.update(db, pk, obj)
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_role_menu(*, db: AsyncSession, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
|
||||
"""
|
||||
更新角色菜单
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 角色 ID
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
if menu_ids.menus:
|
||||
menus = await menu_dao.get_all_by_ids(db, list(set(menu_ids.menus)))
|
||||
if {menu.id for menu in menus} != set(menu_ids.menus):
|
||||
raise errors.NotFoundError(msg='菜单不存在')
|
||||
count = await role_dao.update_menus(db, pk, menu_ids)
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_role_scope(*, db: AsyncSession, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
|
||||
"""
|
||||
更新角色数据范围
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 角色 ID
|
||||
:param scope_ids: 权限规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
|
||||
role = await role_dao.get(db, pk)
|
||||
if not role:
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
if scope_ids.scopes:
|
||||
scopes = await data_scope_dao.get_all_by_ids(db, list(set(scope_ids.scopes)))
|
||||
if {scope.id for scope in scopes} != set(scope_ids.scopes):
|
||||
raise errors.NotFoundError(msg='数据范围不存在')
|
||||
count = await role_dao.update_scopes(db, pk, scope_ids)
|
||||
await user_cache_manager.clear_by_role_id(db, [pk])
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, obj: DeleteRoleParam) -> int:
|
||||
"""
|
||||
批量删除角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
|
||||
count = await role_dao.delete(db, obj.pks)
|
||||
await user_cache_manager.clear_by_role_id(db, obj.pks)
|
||||
return count
|
||||
|
||||
|
||||
role_service: RoleService = RoleService()
|
||||
@@ -0,0 +1,122 @@
|
||||
import math
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.dynamic_config import load_user_security_config
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
|
||||
class UserPasswordHistoryService:
|
||||
"""用户密码历史服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def check_status(user_id: int, user_status: int) -> None:
|
||||
"""
|
||||
检查用户状态
|
||||
|
||||
:param user_id: 用户 ID
|
||||
:param user_status: 用户状态
|
||||
:return:
|
||||
"""
|
||||
if not user_status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
|
||||
lock_key = f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}'
|
||||
locked_until_str = await redis_client.get(lock_key)
|
||||
if locked_until_str:
|
||||
locked_until = timezone.from_str(locked_until_str)
|
||||
now = timezone.now()
|
||||
if locked_until > now:
|
||||
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
|
||||
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
|
||||
await redis_client.delete(lock_key)
|
||||
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
|
||||
@staticmethod
|
||||
async def handle_login_failure(db: AsyncSession, user_id: int) -> None:
|
||||
"""
|
||||
处理登录失败
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if settings.USER_LOCK_THRESHOLD == 0:
|
||||
return
|
||||
|
||||
failure_key = f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}'
|
||||
failure_count = await redis_client.get(failure_key)
|
||||
failure_count = int(failure_count) if failure_count else 0
|
||||
failure_count += 1
|
||||
await redis_client.set(failure_key, str(failure_count), ex=settings.USER_LOCK_SECONDS)
|
||||
|
||||
if failure_count >= settings.USER_LOCK_THRESHOLD:
|
||||
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
|
||||
await redis_client.set(
|
||||
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
|
||||
timezone.to_str(locked_until),
|
||||
ex=settings.USER_LOCK_SECONDS,
|
||||
)
|
||||
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
|
||||
|
||||
@staticmethod
|
||||
async def check_password_expiry_status(db: AsyncSession, password_changed_time: datetime) -> int | None:
|
||||
"""
|
||||
检查密码过期状态
|
||||
|
||||
:param db: 数据库会话
|
||||
:param password_changed_time: 密码修改时间
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if settings.USER_PASSWORD_EXPIRY_DAYS == 0:
|
||||
return None
|
||||
|
||||
if not password_changed_time:
|
||||
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
|
||||
|
||||
expiry_time = password_changed_time + timedelta(days=settings.USER_PASSWORD_EXPIRY_DAYS)
|
||||
days_remaining = (expiry_time - timezone.now()).days
|
||||
|
||||
if days_remaining < 0:
|
||||
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
|
||||
|
||||
if days_remaining <= settings.USER_PASSWORD_REMINDER_DAYS:
|
||||
return days_remaining
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def handle_login_success(user_id: int) -> None:
|
||||
"""
|
||||
处理登录成功
|
||||
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
|
||||
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
|
||||
|
||||
@staticmethod
|
||||
async def save_password_history(db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
|
||||
"""
|
||||
保存密码历史记录
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 创建密码历史记录参数
|
||||
:return:
|
||||
"""
|
||||
await user_password_history_dao.create(db, obj)
|
||||
|
||||
|
||||
password_security_service: UserPasswordHistoryService = UserPasswordHistoryService()
|
||||
@@ -0,0 +1,315 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_dept import dept_dao
|
||||
from backend.app.admin.crud.crud_role import role_dao
|
||||
from backend.app.admin.crud.crud_user import user_dao
|
||||
from backend.app.admin.model import Role, User
|
||||
from backend.app.admin.schema.user import (
|
||||
AddUserParam,
|
||||
ResetPasswordParam,
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
|
||||
from backend.app.admin.service.user_password_history_service import password_security_service
|
||||
from backend.app.admin.utils.password_security import password_verify, validate_new_password
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import UserPermissionType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.pagination import paging_data
|
||||
from backend.common.response.response_code import CustomErrorCode
|
||||
from backend.common.security.jwt import jwt_decode
|
||||
from backend.common.security.token import get_token, revoke_user_tokens
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def get_userinfo(*, db: AsyncSession, pk: int | None = None, username: str | None = None) -> User:
|
||||
"""
|
||||
获取用户信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param username: 用户名
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_join(db, user_id=pk, username=username)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_roles(*, db: AsyncSession, pk: int) -> Sequence[Role]:
|
||||
"""
|
||||
获取用户所有角色
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_join(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
return user.roles
|
||||
|
||||
@staticmethod
|
||||
async def get_list(*, db: AsyncSession, dept: int, username: str, phone: str, status: int) -> dict[str, Any]:
|
||||
"""
|
||||
获取用户列表
|
||||
|
||||
:param db: 数据库会话
|
||||
:param dept: 部门 ID
|
||||
:param username: 用户名
|
||||
:param phone: 手机号
|
||||
:param status: 状态
|
||||
:return:
|
||||
"""
|
||||
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
|
||||
data = await paging_data(db, user_select)
|
||||
if data['items']:
|
||||
serialized_items = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
|
||||
# 确保返回的是列表,即使只有一个元素
|
||||
data['items'] = [serialized_items] if not isinstance(serialized_items, list) else serialized_items
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
|
||||
"""
|
||||
创建用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param obj: 用户添加参数
|
||||
:return:
|
||||
"""
|
||||
if await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.email and await user_dao.check_email(db, obj.email):
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
if not obj.password:
|
||||
raise errors.RequestError(msg='密码不允许为空')
|
||||
if not await dept_dao.get(db, obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if obj.roles:
|
||||
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
|
||||
if {role.id for role in roles} != set(obj.roles):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
obj.nickname = obj.nickname or obj.username
|
||||
await user_dao.add(db, obj)
|
||||
|
||||
@staticmethod
|
||||
async def update(*, db: AsyncSession, pk: int, obj: UpdateUserParam) -> int:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param obj: 用户更新参数
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get_join(db, user_id=pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
|
||||
raise errors.ConflictError(msg='用户名已注册')
|
||||
if obj.email and obj.email != user.email:
|
||||
email_user = await user_dao.check_email(db, obj.email)
|
||||
if email_user:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
|
||||
raise errors.NotFoundError(msg='部门不存在')
|
||||
if obj.roles:
|
||||
roles = await role_dao.get_all_by_ids(db, list(set(obj.roles)))
|
||||
if {role.id for role in roles} != set(obj.roles):
|
||||
raise errors.NotFoundError(msg='角色不存在')
|
||||
count = await user_dao.update(db, user.id, obj)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # ruff:ignore[complex-structure]
|
||||
"""
|
||||
更新用户权限
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param pk: 用户 ID
|
||||
:param type: 权限类型
|
||||
:return:
|
||||
"""
|
||||
match type:
|
||||
case UserPermissionType.superuser:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
|
||||
case UserPermissionType.staff:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_staff(db, pk, is_staff=not user.is_staff)
|
||||
case UserPermissionType.status:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if pk == request.user.id:
|
||||
raise errors.ForbiddenError(msg='禁止修改自身权限')
|
||||
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
|
||||
case UserPermissionType.multi_login:
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
multi_login = user.is_multi_login if pk != request.user.id else request.user.is_multi_login
|
||||
new_multi_login = not multi_login
|
||||
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
if pk == request.user.id:
|
||||
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
|
||||
if not new_multi_login:
|
||||
await revoke_user_tokens(user.id, exclude_session_uuid=token_payload.session_uuid)
|
||||
else:
|
||||
# 系统管理员修改他人时,他人 token 全部失效
|
||||
if not new_multi_login:
|
||||
await revoke_user_tokens(user.id)
|
||||
case _:
|
||||
raise errors.RequestError(msg='权限类型不存在')
|
||||
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def reset_password(*, db: AsyncSession, pk: int, password: str) -> int:
|
||||
"""
|
||||
重置用户密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:param password: 新密码
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
|
||||
await validate_new_password(db, user.id, password)
|
||||
count = await user_dao.reset_password(db, user.id, password)
|
||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||
await password_security_service.save_password_history(db, history_obj)
|
||||
await user_dao.update_password_changed_time(db, user.id)
|
||||
await revoke_user_tokens(user.id)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
|
||||
"""
|
||||
更新当前用户昵称
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param nickname: 用户昵称
|
||||
:return:
|
||||
"""
|
||||
count = await user_dao.update_nickname(db, user_id, nickname)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
|
||||
"""
|
||||
更新当前用户头像
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param avatar: 头像地址
|
||||
:return:
|
||||
"""
|
||||
count = await user_dao.update_avatar(db, user_id, avatar)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
|
||||
"""
|
||||
更新当前用户邮箱
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param captcha: 邮箱验证码
|
||||
:param email: 邮箱
|
||||
:return:
|
||||
"""
|
||||
captcha_key = f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}'
|
||||
captcha_code = await redis_client.get(captcha_key)
|
||||
if not captcha_code:
|
||||
raise errors.RequestError(msg='验证码已失效,请重新获取')
|
||||
if captcha != captcha_code:
|
||||
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
|
||||
email_user = await user_dao.check_email(db, email)
|
||||
if email_user and email_user.id != user_id:
|
||||
raise errors.ConflictError(msg='邮箱已被绑定')
|
||||
await redis_client.delete(captcha_key)
|
||||
count = await user_dao.update_email(db, user_id, email)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
|
||||
"""
|
||||
更新当前用户密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户 ID
|
||||
:param obj: 密码重置参数
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get(db, user_id)
|
||||
if user.password and not password_verify(obj.old_password, user.password):
|
||||
raise errors.RequestError(msg='原密码错误')
|
||||
if obj.new_password != obj.confirm_password:
|
||||
raise errors.RequestError(msg='两次密码输入不一致')
|
||||
|
||||
await validate_new_password(db, user_id, obj.new_password)
|
||||
count = await user_dao.reset_password(db, user_id, obj.new_password)
|
||||
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
|
||||
await password_security_service.save_password_history(db, history_obj)
|
||||
await user_dao.update_password_changed_time(db, user.id)
|
||||
await revoke_user_tokens(user_id)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
|
||||
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def delete(*, db: AsyncSession, pk: int) -> int:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
:param db: 数据库会话
|
||||
:param pk: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
user = await user_dao.get(db, pk)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
|
||||
count = await user_dao.delete(db, user.id)
|
||||
await revoke_user_tokens(user.id)
|
||||
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
|
||||
|
||||
return count
|
||||
|
||||
|
||||
user_service: UserService = UserService()
|
||||
@@ -0,0 +1,7 @@
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_logout(client: TestClient, token_headers: dict[str, str]) -> None:
|
||||
response = client.post('/auth/logout', headers=token_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['code'] == 200
|
||||
@@ -0,0 +1,98 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.model import data_scope_rule, role_data_scope, role_menu, user_role
|
||||
from backend.core.conf import settings
|
||||
from backend.database.redis import redis_client
|
||||
|
||||
|
||||
class UserCacheManager:
|
||||
"""用户缓存管理"""
|
||||
|
||||
@staticmethod
|
||||
async def clear(user_ids: Sequence[int]) -> None:
|
||||
"""
|
||||
清理用户缓存
|
||||
|
||||
:param user_ids: 用户 ID 列表
|
||||
:return:
|
||||
"""
|
||||
if user_ids:
|
||||
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
|
||||
|
||||
async def clear_by_role_id(self, db: AsyncSession, role_ids: list[int]) -> None:
|
||||
"""
|
||||
通过角色 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param role_ids: 角色 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = select(user_role.c.user_id).where(user_role.c.role_id.in_(role_ids)).distinct()
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_menu_id(self, db: AsyncSession, menu_ids: list[int]) -> None:
|
||||
"""
|
||||
通过菜单 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param menu_ids: 菜单 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_menu, user_role.c.role_id == role_menu.c.role_id)
|
||||
.where(role_menu.c.menu_id.in_(menu_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_data_scope_id(self, db: AsyncSession, scope_ids: list[int]) -> None:
|
||||
"""
|
||||
通过数据范围 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param scope_ids: 数据范围 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
|
||||
.where(role_data_scope.c.data_scope_id.in_(scope_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
async def clear_by_data_rule_id(self, db: AsyncSession, rule_ids: list[int]) -> None:
|
||||
"""
|
||||
通过数据规则 ID 清理用户缓存
|
||||
|
||||
:param db: 数据库会话
|
||||
:param rule_ids: 数据规则 ID 列表
|
||||
:return:
|
||||
"""
|
||||
stmt = (
|
||||
select(user_role.c.user_id)
|
||||
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
|
||||
.join(data_scope_rule, role_data_scope.c.data_scope_id == data_scope_rule.c.data_scope_id)
|
||||
.where(data_scope_rule.c.data_rule_id.in_(rule_ids))
|
||||
.distinct()
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
await self.clear(user_ids)
|
||||
|
||||
|
||||
user_cache_manager: UserCacheManager = UserCacheManager()
|
||||
@@ -0,0 +1,68 @@
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.dynamic_config import load_user_security_config
|
||||
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
|
||||
|
||||
password_hash = PasswordHash((BcryptHasher(),))
|
||||
|
||||
|
||||
def get_hash_password(password: str, salt: bytes | None) -> str:
|
||||
"""
|
||||
使用哈希算法加密密码
|
||||
|
||||
:param password: 密码
|
||||
:param salt: 盐值
|
||||
:return:
|
||||
"""
|
||||
return password_hash.hash(password, salt=salt)
|
||||
|
||||
|
||||
def password_verify(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
密码验证
|
||||
|
||||
:param plain_password: 待验证的密码
|
||||
:param hashed_password: 哈希密码
|
||||
:return:
|
||||
"""
|
||||
return password_hash.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
async def validate_new_password(db: AsyncSession, user_id: int, new_password: str) -> None:
|
||||
"""
|
||||
验证新密码
|
||||
|
||||
:param db: 数据库会话
|
||||
:param user_id: 用户ID
|
||||
:param new_password: 新密码
|
||||
:return:
|
||||
"""
|
||||
await load_user_security_config(db)
|
||||
|
||||
if len(new_password) < settings.USER_PASSWORD_MIN_LENGTH:
|
||||
raise errors.RequestError(msg=f'密码长度不能少于 {settings.USER_PASSWORD_MIN_LENGTH} 个字符')
|
||||
|
||||
if len(new_password) > settings.USER_PASSWORD_MAX_LENGTH:
|
||||
raise errors.RequestError(msg=f'密码长度不能超过 {settings.USER_PASSWORD_MAX_LENGTH} 个字符')
|
||||
|
||||
if not is_has_number(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含数字')
|
||||
|
||||
if not is_has_letter(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含字母')
|
||||
|
||||
if settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR and not is_has_special_char(new_password):
|
||||
raise errors.RequestError(msg='密码必须包含特殊字符(如:!@#$%)')
|
||||
|
||||
password_history = await user_password_history_dao.get_by_user_id(db, user_id)
|
||||
|
||||
for hist in password_history[: settings.USER_PASSWORD_HISTORY_CHECK_COUNT]:
|
||||
if password_verify(new_password, hist.password):
|
||||
raise errors.RequestError(
|
||||
msg=f'新密码不能与最近 {settings.USER_PASSWORD_HISTORY_CHECK_COUNT} 次使用的密码相同'
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user