Compare commits

..
72 Commits
Author SHA1 Message Date
Wu Clan c84f0658fb Update the dict pagination query parameters (#689)
* Update the dict pagination query parameters

* Update the dict pagination query parameters

* Update version
2025-06-24 17:34:18 +08:00
Wu Clan a2902bd23a Add built-in plugin missing files (#688) 2025-06-24 17:34:06 +08:00
Wu Clan 69a9b90711 Optimize the zip plug-in file name parsing (#687) 2025-06-24 17:33:57 +08:00
Wu Clan b96402e11d Simplify custom response status codes (#686) 2025-06-24 10:42:15 +08:00
Wu Clan 0bc6f6a719 Update the init test data for SQL scripts (#685)
* Update the init test data for SQL scripts

* Update plugin sql scripts

* fix snowflake id
2025-06-24 10:15:23 +08:00
Wu Clan ebd65c8246 Update dict data label column config (#684) 2025-06-23 22:33:34 +08:00
Wu Clan 234bf708b3 Fix the code with outdated system config (#683) 2025-06-23 22:23:06 +08:00
Dylan 6d5e741d94 Optimize api with semantic HTTP status codes (#681) 2025-06-23 22:18:17 +08:00
Wu Clan f9bfe8f510 Add dictionary type and datas queries (#679) 2025-06-23 10:58:55 +08:00
Wu Clan 408c866dda Update cache cleanup for logout interface (#678)
* Update cache cleanup for logout interface

* Add dependencies of interface
2025-06-22 23:41:48 +08:00
Wu Clan bac41a46f8 Optimize token detection and caching logic (#677) 2025-06-21 20:18:13 +08:00
Wu Clan 8638c26db1 Add the snowflake ID sql script (#675) 2025-06-20 20:31:44 +08:00
Wu Clan 319ba13df1 Optimize routes to better align with RESTful (#673)
* Optimize routes to better align with RESTful

* Add codes endpoint description

* Update jinja templates

* fix typo

* fix sql
2025-06-19 11:06:34 +08:00
Wu Clan 0d1f05d307 Fix some error class import (#672) 2025-06-17 15:30:35 +08:00
Wu Clan e6608d18ce Update the changelog for v1.5.1 (#671) 2025-06-16 21:38:13 +08:00
Dylan 7afd8415cd Add support for snowflake ID primary key (#670)
* fix: 修复PostgreSQL SQL语法错误,将反引号替换为双引号

* feat: 新增雪花算法ID实现

* 优化雪花算法和主键类型

* 修复错误引用

* 添加雪花详情链接

* feat: add snowflake ID parser method

* 修复独立执行异常

* 更新系统时间错误类
2025-06-16 13:34:27 +08:00
Dylan 845f2f0ff8 Fix the postgresql sql script syntax error (#669) 2025-06-15 16:38:48 +08:00
Wu Clan 11d7792c0f Bump sqlalchemy crud plus version to 1.10.0 (#668)
* Bump sqlalchemy crud plus version to 1.10.0

* Update filter style

* Update filters style
2025-06-14 12:35:19 +08:00
Wu Clan 6883ec34c6 Fix the sidebar menu type filtering (#667) 2025-06-13 16:50:53 +08:00
Wu Clan 4c45e7ff27 Update the changelog for v1.5.0 (#664) 2025-06-09 21:36:54 +08:00
Wu Clan 5621daea98 Update the version to 1.5.0 (#663) 2025-06-09 21:32:12 +08:00
Wu Clan 71d168dfea Add data permission in init test sql (#662) 2025-06-09 21:00:49 +08:00
Wu Clan 2d4365189d Update menu sort in init test sql (#661) 2025-06-09 20:27:56 +08:00
Wu Clan 0c28a46e60 Update auth failed default status code (#660)
* Update auth failed default status code

* update init sql
2025-06-09 19:58:06 +08:00
Wu Clan a4c7dae99c Update the plugin download interface permission (#659) 2025-06-09 19:10:48 +08:00
Wu Clan bfda0a226c Update code generate interface permission (#658) 2025-06-09 19:10:40 +08:00
Wu Clan c9bbb105a3 Update data scope and rule to m2m (#657) 2025-06-09 19:10:15 +08:00
Wu Clan b415dc8912 Add OAuth2 user to auto bind a role (#656) 2025-06-08 20:18:24 +08:00
Wu Clan 05b74d71a3 Simplify OAuth2 model and optimize auth service (#655) 2025-06-08 16:51:19 +08:00
Wu Clan 10c0c691a7 Update user email and phone operation logic (#654) 2025-06-08 16:43:51 +08:00
Wu Clan 0bc3786fc6 Update OAuth2 callback interface return (#653) 2025-06-06 20:35:25 +08:00
Wu Clan 8e041ccb1b Update the changelog for v1.4.3 (#651) 2025-06-02 20:43:12 +08:00
Wu Clan a82e9b7b9d Fix data permission condition for filter data (#650) 2025-06-01 00:51:31 +08:00
Wu Clan 7bab8cc636 Update default value for role filter scopes (#649) 2025-05-31 23:53:47 +08:00
Wu Clan 7659b63d88 Add data permission condition for filter data (#648) 2025-05-31 23:40:59 +08:00
Wu Clan 33ebc5723f Add get all data scope rules interface (#647) 2025-05-31 23:09:26 +08:00
Wu Clan 86d9efd785 Update the server startup time to string (#646) 2025-05-31 17:26:07 +08:00
Wu Clan 57ae7f03a6 Refactor routes to better align with RESTful (#645) 2025-05-31 17:01:44 +08:00
Wu Clan 6ee3983846 Fix the filter of query all menus (#642) 2025-05-30 22:48:30 +08:00
Wu Clan f05ed5cdda Fix the menu delete interface arg description (#641) 2025-05-30 18:58:33 +08:00
Wu Clan 187dfa5344 Fix the role update business variables (#640) 2025-05-30 18:56:50 +08:00
Wu Clan 918c8a9d33 Update the changelog for v1.4.2 (#639) 2025-05-29 18:12:45 +08:00
Wu Clan ccfe35ea85 Simplify the user info update business (#638) 2025-05-29 18:09:50 +08:00
yzbf 4a118c166a Fix overwriting existing init files in code generator (#637) 2025-05-28 20:17:18 +08:00
Huyuwei 5f3823cb6d Fix auto-increment id for postgres init data (#632)
* fix: Reset auto-increment values for all tables based on max id

* refactor: Remove auto-increment reset for mysql

* remove unused code
2025-05-28 13:01:44 +08:00
Wu Clan 19ef52da9b Optimize install and build of plugin zip (#636)
* Optimiz install and build of plugin zip

* fix import
2025-05-27 11:46:17 +08:00
Wu Clan 39c3339a8d Optimize role-related data processing performance (#635) 2025-05-26 20:04:13 +08:00
Wu Clan 9f52629dcb Update the captcha invalidation error class (#634) 2025-05-26 19:16:56 +08:00
Wu Clan 1cddd6e2d5 Update non-linked sidebar support (#633)
* Update non-linked sidebar support

* fix typing
2025-05-26 15:52:45 +08:00
Wu Clan ed10e6ff47 Update the changelog for v1.4.1 (#630) 2025-05-25 14:19:41 +08:00
Wu Clan 48574fb50a Update the token decode for logout interface (#629) 2025-05-25 14:16:36 +08:00
Wu Clan ee7fe3087d Update the token check for logout interface (#625) 2025-05-23 22:05:26 +08:00
Wu Clan 270ac3d941 Refactor the system token to online users (#624) 2025-05-23 20:18:47 +08:00
Wu Clan 2f76988076 Add the deepwiki badge to README (#623) 2025-05-23 12:09:04 +08:00
Wu Clan b275481742 Update the menu path and type columns (#622)
* Update the menu path and type columns

* Update build vben5 tree

* Update data
2025-05-22 21:22:24 +08:00
Wu Clan f3f1ceb595 Update the changelog for v1.4.0 (#621) 2025-05-22 14:41:03 +08:00
Wu Clan bcc764142b Update the OAuth2 module to plugin (#620)
* Update the OAuth2 module to plugin

* update create user social param
2025-05-22 14:36:23 +08:00
Wu Clan 9b0d9f8296 Update uv installation in docker deploy (#619) 2025-05-22 14:30:53 +08:00
Wu Clan 63ba7646c1 Update the build plugin api params (#615)
* Update the build plugin api params

* update seek

* fix lint
2025-05-20 19:45:22 +08:00
Wu Clan 1a7734185f Fix non-asyncio nested async IO (#610)
* Fix non-asyncio nested async IO

* Add redis init ping
2025-05-20 18:11:11 +08:00
Wu Clan 8541059d23 Update uninstall and build plugin api method (#614) 2025-05-20 18:10:29 +08:00
Wu Clan 61edc81333 Fix the plugin status update logic (#613) 2025-05-20 17:50:07 +08:00
Wu Clan 8998687adb Fix the task result schema param type (#611) 2025-05-20 15:10:15 +08:00
Wu Clan 85b0294da9 Update the new plugin status to changed (#607)
* Fix cache update after plugin uninstall

* Update new to changed
2025-05-17 09:21:11 +08:00
Wu Clan a8ecb4ae3b Add new plugin status check interface (#606) 2025-05-17 09:06:06 +08:00
Wu Clan a87ff74b56 Update the changelog for v1.3.0 (#605) 2025-05-16 14:16:59 +08:00
Wu Clan 3c5682988d Update the casbin RBAC module path (#604) 2025-05-16 11:49:18 +08:00
Wu Clan 1d32154498 Optimize the zip plugin install logic (#603) 2025-05-15 23:40:33 +08:00
Wu Clan f4290ddb8c Fix the fastapi cli startup event loop (#602) 2025-05-15 22:54:25 +08:00
Wu Clan 1e54b0ba42 Add plugin info config and interfaces (#601)
* Add plugin info config and interfaces

* Remove test data
2025-05-15 20:50:24 +08:00
Wu Clan 27b9cf4c64 Simplify apps and plugins config method (#600) 2025-05-13 13:16:06 +08:00
Wu Clan 87f29e52a1 Update the changelog for v1.2.0 (#598) 2025-05-05 19:46:41 +08:00
160 changed files with 3163 additions and 3087 deletions
+4 -4
View File
@@ -2,13 +2,13 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: check-added-large-files # - id: check-added-large-files
- id: end-of-file-fixer # - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.11.2 rev: v0.11.9
hooks: hooks:
- id: ruff - id: ruff
args: args:
@@ -20,7 +20,7 @@ repos:
- id: ruff-format - id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.10 rev: 0.7.3
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+161
View File
@@ -1,3 +1,156 @@
<a id="v1.5.1"></a>
# [v1.5.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.5.1) - 2025-06-16
## What's Changed
* Update changelog for v1.5.0 by [@wu-clan](https://github.com/wu-clan) in [#664](https://github.com/fastapi-practices/fastapi_best_architecture/pull/664)
* Fix the sidebar menu type filtering by [@wu-clan](https://github.com/wu-clan) in [#667](https://github.com/fastapi-practices/fastapi_best_architecture/pull/667)
* Bump sqlalchemy crud plus version to 1.10.0 by [@wu-clan](https://github.com/wu-clan) in [#668](https://github.com/fastapi-practices/fastapi_best_architecture/pull/668)
* Fix the postgresql sql script syntax error by [@downdawn](https://github.com/downdawn) in [#669](https://github.com/fastapi-practices/fastapi_best_architecture/pull/669)
* Add Initial Snowflake ID Support by [@downdawn](https://github.com/downdawn) in [#670](https://github.com/fastapi-practices/fastapi_best_architecture/pull/670)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.0...v1.5.1
[Changes][v1.5.1]
<a id="v1.5.0"></a>
# [v1.5.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.5.0) - 2025-06-09
## What's Changed
* Update changelog for v1.4.3 by [@wu-clan](https://github.com/wu-clan) in [#651](https://github.com/fastapi-practices/fastapi_best_architecture/pull/651)
* Update OAuth2 callback interface return by [@wu-clan](https://github.com/wu-clan) in [#653](https://github.com/fastapi-practices/fastapi_best_architecture/pull/653)
* Update user email and phone operation logic by [@wu-clan](https://github.com/wu-clan) in [#654](https://github.com/fastapi-practices/fastapi_best_architecture/pull/654)
* Simplify OAuth2 model and optimize auth service by [@wu-clan](https://github.com/wu-clan) in [#655](https://github.com/fastapi-practices/fastapi_best_architecture/pull/655)
* Add OAuth2 user to auto bind a role by [@wu-clan](https://github.com/wu-clan) in [#656](https://github.com/fastapi-practices/fastapi_best_architecture/pull/656)
* Update data scope and rule to m2m by [@wu-clan](https://github.com/wu-clan) in [#657](https://github.com/fastapi-practices/fastapi_best_architecture/pull/657)
* Update code generate interface permission by [@wu-clan](https://github.com/wu-clan) in [#658](https://github.com/fastapi-practices/fastapi_best_architecture/pull/658)
* Update the plugin download interface permission by [@wu-clan](https://github.com/wu-clan) in [#659](https://github.com/fastapi-practices/fastapi_best_architecture/pull/659)
* Update auth failed default status code by [@wu-clan](https://github.com/wu-clan) in [#660](https://github.com/fastapi-practices/fastapi_best_architecture/pull/660)
* Update menu sort in init test sql by [@wu-clan](https://github.com/wu-clan) in [#661](https://github.com/fastapi-practices/fastapi_best_architecture/pull/661)
* Add data permission in init test sql by [@wu-clan](https://github.com/wu-clan) in [#662](https://github.com/fastapi-practices/fastapi_best_architecture/pull/662)
* Update the version to 1.5.0 by [@wu-clan](https://github.com/wu-clan) in [#663](https://github.com/fastapi-practices/fastapi_best_architecture/pull/663)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.3...v1.5.0
[Changes][v1.5.0]
<a id="v1.4.3"></a>
# [v1.4.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.3) - 2025-06-02
## What's Changed
* Update changelog for v1.4.2 by [@wu-clan](https://github.com/wu-clan) in [#639](https://github.com/fastapi-practices/fastapi_best_architecture/pull/639)
* Fix the role update business variables by [@wu-clan](https://github.com/wu-clan) in [#640](https://github.com/fastapi-practices/fastapi_best_architecture/pull/640)
* Fix the menu delete interface arg description by [@wu-clan](https://github.com/wu-clan) in [#641](https://github.com/fastapi-practices/fastapi_best_architecture/pull/641)
* Fix the filter of query all menus by [@wu-clan](https://github.com/wu-clan) in [#642](https://github.com/fastapi-practices/fastapi_best_architecture/pull/642)
* Refactor routes to better align with RESTful by [@wu-clan](https://github.com/wu-clan) in [#645](https://github.com/fastapi-practices/fastapi_best_architecture/pull/645)
* Update the server startup time to string by [@wu-clan](https://github.com/wu-clan) in [#646](https://github.com/fastapi-practices/fastapi_best_architecture/pull/646)
* Add get all data scope rules interface by [@wu-clan](https://github.com/wu-clan) in [#647](https://github.com/fastapi-practices/fastapi_best_architecture/pull/647)
* Add data permission condition for filter data by [@wu-clan](https://github.com/wu-clan) in [#648](https://github.com/fastapi-practices/fastapi_best_architecture/pull/648)
* Update default value for role filter scopes by [@wu-clan](https://github.com/wu-clan) in [#649](https://github.com/fastapi-practices/fastapi_best_architecture/pull/649)
* Fix data permission condition for filter data by [@wu-clan](https://github.com/wu-clan) in [#650](https://github.com/fastapi-practices/fastapi_best_architecture/pull/650)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.2...v1.4.3
[Changes][v1.4.3]
<a id="v1.4.2"></a>
# [v1.4.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.2) - 2025-05-29
## What's Changed
* Update changelog for v1.4.1 by [@wu-clan](https://github.com/wu-clan) in [#630](https://github.com/fastapi-practices/fastapi_best_architecture/pull/630)
* Update non-linked sidebar support by [@wu-clan](https://github.com/wu-clan) in [#633](https://github.com/fastapi-practices/fastapi_best_architecture/pull/633)
* Update the captcha invalidation error class by [@wu-clan](https://github.com/wu-clan) in [#634](https://github.com/fastapi-practices/fastapi_best_architecture/pull/634)
* Optimize role-related data processing performance by [@wu-clan](https://github.com/wu-clan) in [#635](https://github.com/fastapi-practices/fastapi_best_architecture/pull/635)
* Optimize install and build of plugin zip by [@wu-clan](https://github.com/wu-clan) in [#636](https://github.com/fastapi-practices/fastapi_best_architecture/pull/636)
* Fix auto-increment id for postgres init data by [@huyuwei1996](https://github.com/huyuwei1996) in [#632](https://github.com/fastapi-practices/fastapi_best_architecture/pull/632)
* Fix: prevent overwriting existing init files in code generator by [@lin-wu-1990](https://github.com/lin-wu-1990) in [#637](https://github.com/fastapi-practices/fastapi_best_architecture/pull/637)
* Simplify the user info update business by [@wu-clan](https://github.com/wu-clan) in [#638](https://github.com/fastapi-practices/fastapi_best_architecture/pull/638)
## New Contributors
* [@lin-wu-1990](https://github.com/lin-wu-1990) made their first contribution in [#637](https://github.com/fastapi-practices/fastapi_best_architecture/pull/637)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.1...v1.4.2
[Changes][v1.4.2]
<a id="v1.4.1"></a>
# [v1.4.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.1) - 2025-05-25
## What's Changed
* Update changelog for v1.4.0 by [@wu-clan](https://github.com/wu-clan) in [#621](https://github.com/fastapi-practices/fastapi_best_architecture/pull/621)
* Update the menu path and type columns by [@wu-clan](https://github.com/wu-clan) in [#622](https://github.com/fastapi-practices/fastapi_best_architecture/pull/622)
* Add the deepwiki badge to README by [@wu-clan](https://github.com/wu-clan) in [#623](https://github.com/fastapi-practices/fastapi_best_architecture/pull/623)
* Refactor the system token to online users by [@wu-clan](https://github.com/wu-clan) in [#624](https://github.com/fastapi-practices/fastapi_best_architecture/pull/624)
* Update the token check for logout interface by [@wu-clan](https://github.com/wu-clan) in [#625](https://github.com/fastapi-practices/fastapi_best_architecture/pull/625)
* Update the token decode for logout interface by [@wu-clan](https://github.com/wu-clan) in [#629](https://github.com/fastapi-practices/fastapi_best_architecture/pull/629)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
[Changes][v1.4.1]
<a id="v1.4.0"></a>
# [v1.4.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.0) - 2025-05-22
## What's Changed
* Update changelog for v1.3.0 by [@wu-clan](https://github.com/wu-clan) in [#605](https://github.com/fastapi-practices/fastapi_best_architecture/pull/605)
* Add new plugin status check interface by [@wu-clan](https://github.com/wu-clan) in [#606](https://github.com/fastapi-practices/fastapi_best_architecture/pull/606)
* Update the new plugin status to changed by [@wu-clan](https://github.com/wu-clan) in [#607](https://github.com/fastapi-practices/fastapi_best_architecture/pull/607)
* Fix the task result schema param type by [@wu-clan](https://github.com/wu-clan) in [#611](https://github.com/fastapi-practices/fastapi_best_architecture/pull/611)
* Fix the plugin status update logic by [@wu-clan](https://github.com/wu-clan) in [#613](https://github.com/fastapi-practices/fastapi_best_architecture/pull/613)
* Update uninstall and build plugin api method by [@wu-clan](https://github.com/wu-clan) in [#614](https://github.com/fastapi-practices/fastapi_best_architecture/pull/614)
* Fix non-asyncio nested async IO by [@wu-clan](https://github.com/wu-clan) in [#610](https://github.com/fastapi-practices/fastapi_best_architecture/pull/610)
* Update the build plugin api params by [@wu-clan](https://github.com/wu-clan) in [#615](https://github.com/fastapi-practices/fastapi_best_architecture/pull/615)
* Update uv installation in docker deploy by [@wu-clan](https://github.com/wu-clan) in [#619](https://github.com/fastapi-practices/fastapi_best_architecture/pull/619)
* Update the OAuth2 module to plugin by [@wu-clan](https://github.com/wu-clan) in [#620](https://github.com/fastapi-practices/fastapi_best_architecture/pull/620)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[Changes][v1.4.0]
<a id="v1.3.0"></a>
# [v1.3.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.3.0) - 2025-05-16
## What's Changed
* Update changelog for v1.2.0 by [@wu-clan](https://github.com/wu-clan) in [#598](https://github.com/fastapi-practices/fastapi_best_architecture/pull/598)
* Simplify apps and plugins config method by [@wu-clan](https://github.com/wu-clan) in [#600](https://github.com/fastapi-practices/fastapi_best_architecture/pull/600)
* Add plugin info config and interfaces by [@wu-clan](https://github.com/wu-clan) in [#601](https://github.com/fastapi-practices/fastapi_best_architecture/pull/601)
* Fix the fastapi cli startup event loop by [@wu-clan](https://github.com/wu-clan) in [#602](https://github.com/fastapi-practices/fastapi_best_architecture/pull/602)
* Optimize the zip plugin install logic by [@wu-clan](https://github.com/wu-clan) in [#603](https://github.com/fastapi-practices/fastapi_best_architecture/pull/603)
* Update the casbin RBAC module path by [@wu-clan](https://github.com/wu-clan) in [#604](https://github.com/fastapi-practices/fastapi_best_architecture/pull/604)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
[Changes][v1.3.0]
<a id="v1.2.0"></a>
# [v1.2.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.2.0) - 2025-05-01
## What's Changed
* Update changelog for v1.1.2 by [@wu-clan](https://github.com/wu-clan) in [#589](https://github.com/fastapi-practices/fastapi_best_architecture/pull/589)
* Update code generator table columns by [@wu-clan](https://github.com/wu-clan) in [#590](https://github.com/fastapi-practices/fastapi_best_architecture/pull/590)
* Update the default RBAC solution by [@wu-clan](https://github.com/wu-clan) in [#593](https://github.com/fastapi-practices/fastapi_best_architecture/pull/593)
* Optimize the server information retrieval by [@wu-clan](https://github.com/wu-clan) in [#595](https://github.com/fastapi-practices/fastapi_best_architecture/pull/595)
* Refactor the data rule to scope rule by [@wu-clan](https://github.com/wu-clan) in [#596](https://github.com/fastapi-practices/fastapi_best_architecture/pull/596)
* Update the SQL script for creat tables by [@wu-clan](https://github.com/wu-clan) in [#597](https://github.com/fastapi-practices/fastapi_best_architecture/pull/597)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
[Changes][v1.2.0]
<a id="v1.1.2"></a> <a id="v1.1.2"></a>
# [v1.1.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.2) - 2025-04-23 # [v1.1.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.2) - 2025-04-23
@@ -477,6 +630,14 @@
[Changes][v1.0.0] [Changes][v1.0.0]
[v1.5.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.0...v1.5.1
[v1.5.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.3...v1.5.0
[v1.4.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.2...v1.4.3
[v1.4.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.1...v1.4.2
[v1.4.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
[v1.4.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[v1.3.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
[v1.1.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2 [v1.1.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
[v1.1.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1 [v1.1.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
[v1.1.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0 [v1.1.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
+1 -2
View File
@@ -2,8 +2,7 @@
ARG SERVER_TYPE=fastapi_server ARG SERVER_TYPE=fastapi_server
# === Python environment from uv === # === Python environment from uv ===
FROM python:3.10-slim AS builder FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Used for build Python packages # Used for build Python packages
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
English | [简体中文](./README.zh-CN.md)
Enterprise-level backend architecture solution Enterprise-level backend architecture solution
**🔥Continuously updated and maintained🔥** English | [简体中文](./README.zh-CN.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@ Enterprise-level backend architecture solution
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> This repository as a template library open to any person or enterprise can be used for free!
## Pseudo 3-tier architecture ## Pseudo 3-tier architecture
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating
@@ -45,43 +41,7 @@ pattern, use templates to transform it to your heart's content!
| data access | dao / mapper | crud | | data access | dao / mapper | crud |
| model | model / entity | model | | model | model / entity | model |
## Features ## Help
- [x] Global FastAPI PEP 593 Annotated parameter style
- [x] Comprehensive async/await + asgiref asynchronous design
- [x] Adheres to RESTful API specifications
- [x] Uses SQLAlchemy 2.0 with new syntax
- [x] Uses Pydantic v2 version
- [x] Implements role-menu RBAC access control
- [x] Integrates Casbin RBAC access control
- [x] Supports Celery asynchronous tasks
- [x] Custom-developed JWT authentication middleware
- [x] Supports global custom time zones
- [x] Supports Docker / Docker-compose deployment
- [x] Integrates Pytest unit testing
## Built-in Functions
- [x] User Management: Assign roles and permissions
- [x] Department Management: Configure organizational structure (company, department, team, etc.)
- [x] Menu Management: Set up menus and button-level permissions
- [x] Role Management: Configure roles, assign menus and permissions
- [x] Dictionary Management: Maintain common parameters and configurations
- [x] Parameter Management: Dynamically configure commonly used system parameters
- [x] Notification Announcements: Publish and maintain system notification and announcement information
- [x] Token Management: Detect online status, support forced logout
- [x] Multi-device Login: Support one-click switching between multi-device login modes
- [x] OAuth 2.0: Built-in custom-developed OAuth 2.0 authorization login
- [x] Plugin System: Hot-swappable plugin design to reduce coupling
- [x] Scheduled Tasks: Support scheduled, asynchronous tasks, and function calls
- [x] Code Generation: Automatically generate code with preview, write, and download support
- [x] Operation Logs: Record and query normal and abnormal operations
- [x] Login Logs: Record and query normal and abnormal logins
- [x] Cache Monitoring: Query system cache information and command statistics
- [x] Service Monitoring: View server hardware information and status
- [x] API Documentation: Automatically generate online interactive API documentation
## Development and deployment
For more details, please check For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+3 -43
View File
@@ -4,11 +4,9 @@
# FastAPI Best Architecture # FastAPI Best Architecture
简体中文 | [English](./README.md)
企业级后端架构解决方案 企业级后端架构解决方案
**🔥持续更新维护中🔥** 简体中文 | [English](./README.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
@@ -21,12 +19,10 @@
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344) ![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div> </div>
> [!NOTE]
> 此仓库作为模板库公开,任何人或企业均可免费使用!
## 伪三层架构 ## 伪三层架构
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷 mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
@@ -43,43 +39,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud | | 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model | | 模型 | model / entity | model |
## 特性 ## 帮助
- [x] 全局 FastAPI PEP 593 Annotated 参数风格
- [x] 全面 async/await + asgiref 异步设计
- [x] 遵循 RESTful API 规范
- [x] 使用 SQLAlchemy 2.0 全新语法
- [x] 使用 Pydantic v2 版本
- [x] 实现角色菜单 RBAC 访问控制
- [x] 集成 Casbin RBAC 访问控制
- [x] 支持 Celery 异步任务
- [x] 自研 JWT 认证中间件
- [x] 支持全局自定义时间时区
- [x] 支持 Docker / Docker-compose 部署
- [x] 集成 Pytest 单元测试
## 内置功能
- [x] 用户管理:分配角色和权限
- [x] 部门管理:配置组织架构(公司、部门、小组等)
- [x] 菜单管理:设置菜单及按钮级权限
- [x] 角色管理:配置角色、分配菜单和权限
- [x] 字典管理:维护常用参数和配置
- [x] 参数管理:系统常用参数动态配置
- [x] 通知公告:发布和维护系统通知公告信息
- [x] 令牌管理:检测在线状态,支持强制下线
- [x] 多端登录:支持一键切换多端登录模式
- [x] OAuth 2.0:内置自研 OAuth 2.0 授权登录
- [x] 插件系统:热插拔插件设计,降低耦合
- [x] 定时任务:支持定时,异步任务及函数调用
- [x] 代码生成:自动生成代码,支持预览、写入和下载
- [x] 操作日志:记录和查询正常和异常操作
- [x] 登录日志:记录和查询正常和异常登录
- [x] 缓存监控:查询系统缓存信息和命令统计
- [x] 服务监控:查看服务器硬件信息和状态
- [x] 接口文档:自动生成在线交互式 API 文档
## 开发部署
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) 更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
+6 -6
View File
@@ -15,18 +15,18 @@ REDIS_DATABASE=0
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log # Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b' OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# Admin # App Admin
# OAuth2 # OAuth2
OAUTH2_GITHUB_CLIENT_ID='test' OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test' OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test' OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test' OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# Task # App Task
# Celery # Celery
CELERY_BROKER_REDIS_DATABASE=1 CELERY_BROKER_REDIS_DATABASE=1
CELERY_BACKEND_REDIS_DATABASE=2 CELERY_BACKEND_REDIS_DATABASE=2
# Rabbitmq # Rabbitmq
RABBITMQ_HOST='127.0.0.1' CELERY_RABBITMQ_HOST='127.0.0.1'
RABBITMQ_PORT=5672 CELERY_RABBITMQ_PORT=5672
RABBITMQ_USERNAME='guest' CELERY_RABBITMQ_USERNAME='guest'
RABBITMQ_PASSWORD='guest' CELERY_RABBITMQ_PASSWORD='guest'
-2
View File
@@ -5,14 +5,12 @@ from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router 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.log import router as log_router
from backend.app.admin.api.v1.monitor import router as monitor_router from backend.app.admin.api.v1.monitor import router as monitor_router
from backend.app.admin.api.v1.oauth2 import router as oauth2_router
from backend.app.admin.api.v1.sys import router as sys_router from backend.app.admin.api.v1.sys import router as sys_router
from backend.core.conf import settings from backend.core.conf import settings
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
v1.include_router(auth_router) v1.include_router(auth_router)
v1.include_router(oauth2_router)
v1.include_router(sys_router) v1.include_router(sys_router)
v1.include_router(log_router) v1.include_router(log_router)
v1.include_router(monitor_router) v1.include_router(monitor_router)
+1 -1
View File
@@ -8,4 +8,4 @@ from backend.app.admin.api.v1.auth.captcha import router as captcha_router
router = APIRouter(prefix='/auth') router = APIRouter(prefix='/auth')
router.include_router(auth_router, tags=['授权']) router.include_router(auth_router, tags=['授权'])
router.include_router(captcha_router, prefix='/captcha', tags=['验证码']) router.include_router(captcha_router, tags=['验证码'])
+13 -7
View File
@@ -17,9 +17,9 @@ router = APIRouter()
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证') @router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
async def swagger_login(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken: async def login_swagger(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(obj=obj) token, user = await auth_service.swagger_login(obj=obj)
return GetSwaggerToken(access_token=token, user=user) # type: ignore return GetSwaggerToken(access_token=token, user=user)
@router.post( @router.post(
@@ -28,20 +28,26 @@ async def swagger_login(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetS
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman', description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
dependencies=[Depends(RateLimiter(times=5, minutes=1))], dependencies=[Depends(RateLimiter(times=5, minutes=1))],
) )
async def user_login( async def login(
request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
) -> ResponseSchemaModel[GetLoginToken]: ) -> ResponseSchemaModel[GetLoginToken]:
data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks) data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks)
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/token/new', summary='创建新 token') @router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]: async def get_codes(request: Request) -> ResponseSchemaModel[list[str]]:
data = await auth_service.new_token(request=request) codes = await auth_service.get_codes(request=request)
return response_base.success(data=codes)
@router.post('/tokens', summary='刷新 token')
async def refresh_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(request=request)
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) @router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth])
async def user_logout(request: Request, response: Response) -> ResponseModel: async def logout(request: Request, response: Response) -> ResponseModel:
await auth_service.logout(request=request, response=response) await auth_service.logout(request=request, response=response)
return response_base.success() return response_base.success()
+4 -4
View File
@@ -5,16 +5,16 @@ from fastapi import APIRouter, Depends, Request
from fastapi_limiter.depends import RateLimiter from fastapi_limiter.depends import RateLimiter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.admin.conf import admin_settings
from backend.app.admin.schema.captcha import GetCaptchaDetail from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@router.get( @router.get(
'', '/captcha',
summary='获取登录验证码', summary='获取登录验证码',
dependencies=[Depends(RateLimiter(times=5, seconds=10))], dependencies=[Depends(RateLimiter(times=5, seconds=10))],
) )
@@ -26,9 +26,9 @@ async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]
img, code = await run_in_threadpool(img_captcha, img_byte=img_type) img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
ip = request.state.ip ip = request.state.ip
await redis_client.set( await redis_client.set(
f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}', f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}',
code, code,
ex=admin_settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
) )
data = GetCaptchaDetail(image_type=img_type, image=img) data = GetCaptchaDetail(image_type=img_type, image=img)
return response_base.success(data=data) return response_base.success(data=data)
+5 -5
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.login_log import GetLoginLogDetail from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
from backend.app.admin.service.login_log_service import login_log_service from backend.app.admin.service.login_log_service import login_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -24,7 +24,7 @@ router = APIRouter()
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_login_logs( async def get_login_logs_paged(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
@@ -43,8 +43,8 @@ async def get_pagination_login_logs(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_login_log(pk: Annotated[list[int], Query(description='登录日志 ID 列表')]) -> ResponseModel: async def delete_login_logs(obj: DeleteLoginLogParam) -> ResponseModel:
count = await login_log_service.delete(pk=pk) count = await login_log_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -54,7 +54,7 @@ async def delete_login_log(pk: Annotated[list[int], Query(description='登录日
'/all', '/all',
summary='清空登录日志', summary='清空登录日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:login:empty')), Depends(RequestPermission('log:login:clear')),
DependsRBAC, DependsRBAC,
], ],
) )
+5 -5
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.opera_log import GetOperaLogDetail from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
from backend.app.admin.service.opera_log_service import opera_log_service from backend.app.admin.service.opera_log_service import opera_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -24,7 +24,7 @@ router = APIRouter()
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_opera_logs( async def get_opera_logs_paged(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
@@ -43,8 +43,8 @@ async def get_pagination_opera_logs(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日志 ID 列表')]) -> ResponseModel: async def delete_opera_logs(obj: DeleteOperaLogParam) -> ResponseModel:
count = await opera_log_service.delete(pk=pk) count = await opera_log_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -54,7 +54,7 @@ async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日
'/all', '/all',
summary='清空操作日志', summary='清空操作日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:opera:empty')), Depends(RequestPermission('log:opera:clear')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from fastapi import APIRouter 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.redis import router as redis_router
from backend.app.admin.api.v1.monitor.server import router as server_router from backend.app.admin.api.v1.monitor.server import router as server_router
@@ -9,3 +10,4 @@ router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控']) router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控']) router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.token import GetTokenDetail, KickOutToken from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
@@ -18,8 +18,8 @@ from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth]) @router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
async def get_tokens( async def get_sessions(
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]: ) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*') token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
@@ -44,9 +44,10 @@ async def get_tokens(
for key in token_keys: for key in token_keys:
token = await redis_client.get(key) token = await redis_client.get(key)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id
session_uuid = token_payload.session_uuid session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail( token_detail = GetTokenDetail(
id=token_payload.id, id=user_id,
session_uuid=session_uuid, session_uuid=session_uuid,
username='未知', username='未知',
nickname='未知', nickname='未知',
@@ -58,7 +59,7 @@ async def get_tokens(
last_login_time='未知', last_login_time='未知',
expire_time=token_payload.expire_time, expire_time=token_payload.expire_time,
) )
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}') extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if extra_info: if extra_info:
extra_info = json.loads(extra_info) extra_info = json.loads(extra_info)
# 排除 swagger 登录生成的 token # 排除 swagger 登录生成的 token
@@ -75,15 +76,17 @@ async def get_tokens(
@router.delete( @router.delete(
'/{pk}', '/{pk}',
summary='下线', summary='强制下线',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:token:kick')), Depends(RequestPermission('sys:session:delete')),
DependsRBAC, DependsRBAC,
], ],
) )
async def kick_out( async def delete_session(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: KickOutToken request: Request,
pk: Annotated[int, Path(description='用户 ID')],
session_uuid: Annotated[str, Query(description='会话 UUID')],
) -> ResponseModel: ) -> ResponseModel:
superuser_verify(request) superuser_verify(request)
await revoke_token(str(pk), obj.session_uuid) await revoke_token(pk, session_uuid)
return response_base.success() return response_base.success()
@@ -1,11 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.oauth2.github import router as github_router
from backend.app.admin.api.v1.oauth2.linux_do import router as linux_do_router
router = APIRouter(prefix='/oauth2')
router.include_router(github_router, prefix='/github', tags=['GitHub OAuth2'])
router.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])
+3 -5
View File
@@ -5,11 +5,10 @@ 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_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.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.dept import router as dept_router
from backend.app.admin.api.v1.sys.files 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.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.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.role import router as role_router
from backend.app.admin.api.v1.sys.token import router as token_router
from backend.app.admin.api.v1.sys.upload import router as upload_router
from backend.app.admin.api.v1.sys.user import router as user_router from backend.app.admin.api.v1.sys.user import router as user_router
router = APIRouter(prefix='/sys') router = APIRouter(prefix='/sys')
@@ -20,6 +19,5 @@ router.include_router(role_router, prefix='/roles', tags=['系统角色'])
router.include_router(user_router, prefix='/users', tags=['系统用户']) router.include_router(user_router, prefix='/users', tags=['系统用户'])
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则']) router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围']) router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(file_router, prefix='/files', tags=['系统文件'])
router.include_router(upload_router, prefix='/upload', tags=['系统上传']) router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
router.include_router(plugin_router, prefix='/plugin', tags=['系统插件'])
+5 -4
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import ( from backend.app.admin.schema.data_rule import (
CreateDataRuleParam, CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail, GetDataRuleColumnDetail,
GetDataRuleDetail, GetDataRuleDetail,
UpdateDataRuleParam, UpdateDataRuleParam,
@@ -27,7 +28,7 @@ async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
return response_base.success(data=models) return response_base.success(data=models)
@router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth]) @router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns( async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')], model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]: ) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
@@ -57,7 +58,7 @@ async def get_data_rule(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_rules( async def get_data_rules_paged(
db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]: ) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
data_rule_select = await data_rule_service.get_select(name=name) data_rule_select = await data_rule_service.get_select(name=name)
@@ -103,8 +104,8 @@ async def update_data_rule(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_data_rule(pk: Annotated[list[int], Query(description='数据规则 ID 列表')]) -> ResponseModel: async def delete_data_rules(obj: DeleteDataRuleParam) -> ResponseModel:
count = await data_rule_service.delete(pk=pk) count = await data_rule_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+10 -3
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import ( from backend.app.admin.schema.data_scope import (
CreateDataScopeParam, CreateDataScopeParam,
DeleteDataScopeParam,
GetDataScopeDetail, GetDataScopeDetail,
GetDataScopeWithRelationDetail, GetDataScopeWithRelationDetail,
UpdateDataScopeParam, UpdateDataScopeParam,
@@ -22,6 +23,12 @@ from backend.database.db import CurrentSession
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
async def get_all_data_scope() -> ResponseSchemaModel[list[GetDataScopeDetail]]:
data = await data_scope_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope( async def get_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], pk: Annotated[int, Path(description='数据范围 ID')],
@@ -46,7 +53,7 @@ async def get_data_scope_rules(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_scopes( async def get_data_scopes_paged(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None, name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
@@ -111,8 +118,8 @@ async def update_data_scope_rules(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_data_scope(pk: Annotated[list[int], Query(description='数据范围 ID 列表')]) -> ResponseModel: async def delete_data_scopes(obj: DeleteDataScopeParam) -> ResponseModel:
count = await data_scope_service.delete(pk=pk) count = await data_scope_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+3 -3
View File
@@ -20,15 +20,15 @@ async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> Respons
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有部门展示', dependencies=[DependsJwtAuth]) @router.get('', summary='获取部门', dependencies=[DependsJwtAuth])
async def get_all_depts( async def get_dept_tree(
request: Request, request: Request,
name: Annotated[str | None, Query(description='部门名称')] = None, name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None, phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
dept = await dept_service.get_dept_tree(request=request, name=name, leader=leader, phone=phone, status=status) dept = await dept_service.get_tree(request=request, name=name, leader=leader, phone=phone, status=status)
return response_base.success(data=dept) return response_base.success(data=dept)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
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 file_verify, upload_file
router = APIRouter()
@router.post(
'/upload',
summary='文件上传',
dependencies=[
Depends(RequestPermission('sys:file:upload')),
DependsRBAC,
],
)
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
file_verify(file)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
+7 -7
View File
@@ -14,9 +14,9 @@ from backend.common.security.rbac import DependsRBAC
router = APIRouter() router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth]) @router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any]]]: async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
menu = await menu_service.get_user_menu_tree(request=request) menu = await menu_service.get_sidebar(request=request)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -26,12 +26,12 @@ async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> Respons
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有菜单展示', dependencies=[DependsJwtAuth]) @router.get('', summary='获取菜单', dependencies=[DependsJwtAuth])
async def get_all_menus( async def get_menu_tree(
title: Annotated[str | None, Query(description='菜单标题')] = None, title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None, status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_menu_tree(title=title, status=status) menu = await menu_service.get_tree(title=title, status=status)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -71,7 +71,7 @@ async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: Up
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_menu(pk: Annotated[int, Path(description='菜单 ID 列表')]) -> ResponseModel: async def delete_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
count = await menu_service.delete(pk=pk) count = await menu_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+56 -68
View File
@@ -1,98 +1,86 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import io from typing import Annotated, Any
import os.path
import zipfile
from typing import Annotated from fastapi import APIRouter, Depends, File, Path, UploadFile
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.params import Query from fastapi.params import Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from backend.common.exception import errors from backend.app.admin.service.plugin_service import plugin_service
from backend.common.response.response_schema import ResponseModel, response_base 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 DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.core.path_conf import PLUGIN_DIR
from backend.plugin.tools import install_requirements_async
router = APIRouter() router = APIRouter()
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
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=[DependsJwtAuth])
async def plugin_changed() -> ResponseSchemaModel[bool]:
plugins = await plugin_service.changed()
return response_base.success(data=bool(plugins))
@router.post( @router.post(
'/install', '',
summary='安装插件', summary='安装插件',
description='使用插件 zip 压缩包进行安装', description='使用插件 zip 压缩包或 git 仓库地址进行安装',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:install')), Depends(RequestPermission('sys:plugin:install')),
DependsRBAC, DependsRBAC,
], ],
) )
async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: async def install_plugin(
contents = await file.read() type: Annotated[PluginType, Query(description='插件类型')],
file_bytes = io.BytesIO(contents) file: Annotated[UploadFile | None, File()] = None,
if not zipfile.is_zipfile(file_bytes): repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None,
raise errors.ForbiddenError(msg='插件压缩包格式非法') ) -> ResponseModel:
with zipfile.ZipFile(file_bytes) as zf: await plugin_service.install(type=type, file=file, repo_url=repo_url)
# 校验压缩包 return response_base.success(
plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/' res=CustomResponse(code=200, msg='插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务')
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)] )
if not members_in_plugin_dir:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '')
if (
len(members_in_plugin_dir) <= 3
or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir
or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_dir_in_zip):
new_filename = member.filename.replace(plugin_dir_in_zip, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(PLUGIN_DIR, members)
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
await install_requirements_async()
return response_base.success()
@router.post( @router.delete(
'/zip', '/{plugin}',
summary='打包插件', summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:zip')), Depends(RequestPermission('sys:plugin:uninstall')),
DependsRBAC, DependsRBAC,
], ],
) )
async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse: async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
plugin_dir = os.path.join(PLUGIN_DIR, plugin) await plugin_service.uninstall(plugin=plugin)
if not os.path.exists(plugin_dir): return response_base.success(
raise errors.ForbiddenError(msg='插件不存在') res=CustomResponse(code=200, msg='插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
)
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname)
bio.seek(0) @router.put(
'/{plugin}/status',
summary='更新插件状态',
dependencies=[
Depends(RequestPermission('sys:plugin:edit')),
DependsRBAC,
],
)
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=[DependsJwtAuth])
async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
bio = await plugin_service.build(plugin=plugin)
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
+11 -20
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
DeleteRoleParam,
GetRoleDetail, GetRoleDetail,
GetRoleWithRelationDetail, GetRoleWithRelationDetail,
UpdateRoleMenuParam, UpdateRoleMenuParam,
@@ -29,32 +30,22 @@ async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth]) @router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
async def get_user_all_roles( async def get_role_menu_tree(
pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_users(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
async def get_role_all_menus(
pk: Annotated[int, Path(description='角色 ID')], pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
menu = await role_service.get_menu_tree(pk=pk) menu = await role_service.get_menu_tree(pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth]) @router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_all_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: async def get_role_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
rule = await role_service.get_scopes(pk=pk) rule = await role_service.get_scopes(pk=pk)
return response_base.success(data=rule) return response_base.success(data=rule)
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
async def get_role( async def get_role(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(pk=pk) data = await role_service.get(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -67,7 +58,7 @@ async def get_role(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_roles( async def get_roles_paged(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query(description='角色名称')] = None, name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
@@ -106,7 +97,7 @@ async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: Up
@router.put( @router.put(
'/{pk}/menu', '/{pk}/menus',
summary='更新角色菜单', summary='更新角色菜单',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:menu:edit')), Depends(RequestPermission('sys:role:menu:edit')),
@@ -123,7 +114,7 @@ async def update_role_menus(
@router.put( @router.put(
'/{pk}/scope', '/{pk}/scopes',
summary='更新角色数据范围', summary='更新角色数据范围',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:scope:edit')), Depends(RequestPermission('sys:role:scope:edit')),
@@ -147,8 +138,8 @@ async def update_role_scopes(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_role(pk: Annotated[list[int], Query(description='角色 ID 列表')]) -> ResponseModel: async def delete_roles(obj: DeleteRoleParam) -> ResponseModel:
count = await role_service.delete(pk=pk) count = await role_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, File, UploadFile
from backend.common.dataclasses import UploadUrl
from backend.common.enums import FileType
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.file_ops import file_verify, upload_file
router = APIRouter()
@router.post('/image', summary='上传图片', dependencies=[DependsJwtAuth])
async def upload_image(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
file_verify(file, FileType.image)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
@router.post('/video', summary='上传视频', dependencies=[DependsJwtAuth])
async def upload_video(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
file_verify(file, FileType.video)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
+39 -84
View File
@@ -4,17 +4,16 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.role import GetRoleDetail
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
AvatarParam,
GetCurrentUserInfoWithRelationDetail, GetCurrentUserInfoWithRelationDetail,
GetUserInfoWithRelationDetail, GetUserInfoWithRelationDetail,
RegisterUserParam,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
UpdateUserRoleParam,
) )
from backend.app.admin.service.user_service import user_service from backend.app.admin.service.user_service import user_service
from backend.common.enums import UserPermissionType
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
@@ -25,74 +24,24 @@ from backend.database.db import CurrentSession
router = APIRouter() router = APIRouter()
@router.post('/register', summary='注册用户')
async def register_user(obj: RegisterUserParam) -> ResponseModel:
await user_service.register(obj=obj)
return response_base.success()
@router.post('/add', summary='添加用户', dependencies=[DependsRBAC])
async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.add(request=request, obj=obj)
data = await user_service.get_userinfo(username=obj.username)
return response_base.success(data=data)
@router.post('/password/reset', summary='密码重置', dependencies=[DependsJwtAuth])
async def password_reset(request: Request, obj: ResetPasswordParam) -> ResponseModel:
count = await user_service.pwd_reset(request=request, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth]) @router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]: async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
data = request.user.model_dump() data = request.user.model_dump()
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取用户信息', dependencies=[DependsJwtAuth])
async def get_user( async def get_userinfo(
username: Annotated[str, Path(description='用户')], pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]: ) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
data = await user_service.get_userinfo(username=username) data = await user_service.get_userinfo(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) @router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def update_user( async def get_user_roles(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseSchemaModel[list[GetRoleDetail]]:
request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserParam data = await user_service.get_roles(pk=pk)
) -> ResponseModel: return response_base.success(data=data)
count = await user_service.update(request=request, username=username, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{username}/role',
summary='更新用户角色',
dependencies=[
Depends(RequestPermission('sys:user:role:edit')),
DependsRBAC,
],
)
async def update_user_role(
request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserRoleParam
) -> ResponseModel:
await user_service.update_roles(request=request, username=username, obj=obj)
return response_base.success()
@router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth])
async def update_avatar(
request: Request, username: Annotated[str, Path(description='用户名')], avatar: AvatarParam
) -> ResponseModel:
count = await user_service.update_avatar(request=request, username=username, avatar=avatar)
if count > 0:
return response_base.success()
return response_base.fail()
@router.get( @router.get(
@@ -103,7 +52,7 @@ async def update_avatar(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_users( async def get_users_paged(
db: CurrentSession, db: CurrentSession,
dept: Annotated[int | None, Query(description='部门 ID')] = None, dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
@@ -115,49 +64,55 @@ async def get_pagination_users(
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC]) @router.post('', summary='创建用户', dependencies=[DependsRBAC])
async def super_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def create_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
count = await user_service.update_permission(request=request, pk=pk) await user_service.create(request=request, obj=obj)
data = await user_service.get_userinfo(username=obj.username)
return response_base.success(data=data)
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsJwtAuth])
async def update_user(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: UpdateUserParam
) -> ResponseModel:
count = await user_service.update(request=request, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC]) @router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsRBAC])
async def staff_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def update_user_permission(
count = await user_service.update_staff(request=request, pk=pk) request: Request,
pk: Annotated[int, Path(description='用户 ID')],
type: Annotated[UserPermissionType, Query(description='权限类型')],
) -> ResponseModel:
count = await user_service.update_permission(request=request, pk=pk, type=type)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC]) @router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsJwtAuth])
async def status_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def reset_user_password(
count = await user_service.update_status(request=request, pk=pk) pk: Annotated[int, Path(description='用户 ID')], obj: ResetPasswordParam
if count > 0: ) -> ResponseModel:
return response_base.success() count = await user_service.reset_pwd(pk=pk, obj=obj)
return response_base.fail()
@router.put('/{pk}/multi', summary='修改用户多端登录状态', dependencies=[DependsRBAC])
async def multi_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_multi_login(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.delete( @router.delete(
path='/{username}', path='/{pk}',
summary='用户注销', summary='删除用户',
description='用户注销 != 用户登出,注销之后用户将从数据库删除',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:user:del')), Depends(RequestPermission('sys:user:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_user(username: Annotated[str, Path(description='用户')]) -> ResponseModel: async def delete_user(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.delete(username=username) count = await user_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
class AdminSettings(BaseSettings):
"""Admin 配置"""
model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# .env OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
@lru_cache
def get_admin_settings() -> AdminSettings:
"""获取 admin 配置"""
return AdminSettings()
admin_settings = get_admin_settings()
+8 -13
View File
@@ -2,9 +2,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, and_, desc, select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule from backend.app.admin.model import DataRule
@@ -31,16 +30,12 @@ class CRUDDataRule(CRUDPlus[DataRule]):
:param name: 规则名称 :param name: 规则名称
:return: :return:
""" """
stmt = select(self.model).options(noload(self.model.scope)).order_by(desc(self.model.created_time)) filters = {}
filters = []
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if filters: return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters)
stmt = stmt.where(and_(*filters))
return stmt
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None: async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
""" """
@@ -82,15 +77,15 @@ class CRUDDataRule(CRUDPlus[DataRule]):
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除规则 批量删除规则
:param db: 数据库会话 :param db: 数据库会话
:param pk: 规则 ID 列表 :param pks: 规则 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule) data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
+21 -21
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from sqlalchemy import Select, and_, desc, select from typing import Sequence
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, DataScope from backend.app.admin.model import DataRule, DataScope
@@ -40,9 +41,16 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param pk: 范围 ID :param pk: 范围 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.rules)).where(self.model.id == pk) return await self.select_model(db, pk, load_strategies=['rules'])
data_scope = await db.execute(stmt)
return data_scope.scalars().first() async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
"""
获取所有数据范围
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_list(self, name: str | None, status: int | None) -> Select: async def get_list(self, name: str | None, status: int | None) -> Select:
""" """
@@ -52,22 +60,14 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param status: 范围状态 :param status: 范围状态
:return: :return:
""" """
stmt = ( filters = {}
select(self.model)
.options(noload(self.model.rules), noload(self.model.roles))
.order_by(desc(self.model.created_time))
)
filters = []
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if status is not None: if status is not None:
filters.append(self.model.status == status) filters['status'] = status
if filters: return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters)
stmt = stmt.where(and_(*filters))
return stmt
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None: async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
""" """
@@ -105,15 +105,15 @@ class CRUDDataScope(CRUDPlus[DataScope]):
current_data_scope.rules = rules.scalars().all() current_data_scope.rules = rules.scalars().all()
return len(current_data_scope.rules) return len(current_data_scope.rules)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除数据范围 批量删除数据范围
:param db: 数据库会话 :param db: 数据库会话
:param pk: 范围 ID 列表 :param pks: 范围 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope) data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
+11 -14
View File
@@ -3,9 +3,7 @@
from typing import Sequence from typing import Sequence
from fastapi import Request from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
@@ -56,16 +54,19 @@ class CRUDDept(CRUDPlus[Dept]):
:param status: 部门状态 :param status: 部门状态
:return: :return:
""" """
filters = {'del_flag__eq': 0} filters = {'del_flag': 0}
if name is not None: if name is not None:
filters.update(name__like=f'%{name}%') filters['name__like'] = f'%{name}%'
if leader is not None: if leader is not None:
filters.update(leader__like=f'%{leader}%') filters['leader__like'] = f'%{leader}%'
if phone is not None: if phone is not None:
filters.update(phone__startswith=phone) filters['phone__startswith'] = phone
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
return await self.select_models_order(db, 'sort', None, await filter_data_permission(db, request), **filters)
data_filtered = await filter_data_permission(db, request)
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
@@ -106,9 +107,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id) return await self.select_model(db, dept_id, load_strategies=['users'])
result = await db.execute(stmt)
return result.scalars().first()
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]: async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
""" """
@@ -118,9 +117,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).where(self.model.parent_id == dept_id, self.model.del_flag == 0) return await self.select_models(db, parent_id=dept_id, del_flag=0)
result = await db.execute(stmt)
return result.scalars().all()
dept_dao: CRUDDept = CRUDDept(Dept) dept_dao: CRUDDept = CRUDDept(Dept)
+9 -7
View File
@@ -21,12 +21,14 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username__like=f'%{username}%') filters['username__like'] = f'%{username}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
if ip is not None: if ip is not None:
filters.update(ip__like=f'%{ip}%') filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
@@ -39,15 +41,15 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
""" """
await self.create_model(db, obj, commit=True) await self.create_model(db, obj, commit=True)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除登录日志 批量删除登录日志
:param db: 数据库会话 :param db: 数据库会话
:param pk: 登录日志 ID 列表 :param pks: 登录日志 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
+13 -17
View File
@@ -2,9 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import and_, asc, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Menu from backend.app.admin.model import Menu
@@ -44,28 +42,28 @@ class CRUDMenu(CRUDPlus[Menu]):
:return: :return:
""" """
filters = {} filters = {}
if title is not None: if title is not None:
filters.update(title=f'%{title}%') filters['title__like'] = f'%{title}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
return await self.select_models_order(db, 'sort', **filters) return await self.select_models_order(db, 'sort', **filters)
async def get_role_menus(self, db: AsyncSession, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]: async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
""" """
获取角色菜单列表 获取用户的菜单侧边栏
:param db: 数据库会话 :param db: 数据库会话
:param superuser: 是否超级管理员
:param menu_ids: 菜单 ID 列表 :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
stmt = select(self.model).order_by(asc(self.model.sort)) filters = {'type__in': [0, 1, 3, 4]}
filters = [self.model.type.in_([0, 1])]
if not superuser: if menu_ids:
filters.append(self.model.id.in_(menu_ids)) filters['id__in'] = menu_ids
stmt = stmt.where(and_(*filters))
menu = await db.execute(stmt) return await self.select_models_order(db, 'sort', 'asc', **filters)
return menu.scalars().all()
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None: async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
""" """
@@ -106,9 +104,7 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID :param menu_id: 菜单 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id) menu = await self.select_model(db, menu_id, load_strategies=['children'])
result = await db.execute(stmt)
menu = result.scalars().first()
return menu.children return menu.children
+9 -7
View File
@@ -21,12 +21,14 @@ class CRUDOperaLogDao(CRUDPlus[OperaLog]):
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username__like=f'%{username}%') filters['username__like'] = f'%{username}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status__eq'] = status
if ip is not None: if ip is not None:
filters.update(ip__like=f'%{ip}%') filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
@@ -39,15 +41,15 @@ class CRUDOperaLogDao(CRUDPlus[OperaLog]):
""" """
await self.create_model(db, obj) await self.create_model(db, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除操作日志 批量删除操作日志
:param db: 数据库会话 :param db: 数据库会话
:param pk: 操作日志 ID 列表 :param pks: 操作日志 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
+20 -38
View File
@@ -2,12 +2,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, and_, desc, select from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataScope, Menu, Role, User from backend.app.admin.model import DataScope, Menu, Role
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
@@ -37,13 +36,7 @@ class CRUDRole(CRUDPlus[Role]):
:param role_id: 角色 ID :param role_id: 角色 ID
:return: :return:
""" """
stmt = ( return await self.select_model(db, role_id, load_strategies=['menus', 'scopes'])
select(self.model)
.options(selectinload(self.model.menus), selectinload(self.model.scopes))
.where(self.model.id == role_id)
)
role = await db.execute(stmt)
return role.scalars().first()
async def get_all(self, db: AsyncSession) -> Sequence[Role]: async def get_all(self, db: AsyncSession) -> Sequence[Role]:
""" """
@@ -54,18 +47,6 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]:
"""
获取用户角色列表
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
stmt = select(self.model).join(self.model.users).where(User.id == user_id)
roles = await db.execute(stmt)
return roles.scalars().all()
async def get_list(self, name: str | None, status: int | None) -> Select: async def get_list(self, name: str | None, status: int | None) -> Select:
""" """
获取角色列表 获取角色列表
@@ -74,22 +55,23 @@ class CRUDRole(CRUDPlus[Role]):
:param status: 角色状态 :param status: 角色状态
:return: :return:
""" """
stmt = (
select(self.model)
.options(noload(self.model.users), noload(self.model.menus), noload(self.model.scopes))
.order_by(desc(self.model.created_time))
)
filters = [] filters = {}
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if status is not None: if status is not None:
filters.append(self.model.status == status) filters['status'] = status
if filters: return await self.select_order(
stmt = stmt.where(and_(*filters)) 'id',
load_strategies={
return stmt 'users': 'noload',
'menus': 'noload',
'scopes': 'noload',
},
**filters,
)
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None: async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
""" """
@@ -152,15 +134,15 @@ class CRUDRole(CRUDPlus[Role]):
current_role.scopes = scopes.scalars().all() current_role.scopes = scopes.scalars().all()
return len(current_role.scopes) return len(current_role.scopes)
async def delete(self, db: AsyncSession, role_id: list[int]) -> int: async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
""" """
删除角色 批量删除角色
:param db: 数据库会话 :param db: 数据库会话
:param role_id: 角色 ID 列表 :param role_ids: 角色 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_id) return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_ids)
role_dao: CRUDRole = CRUDRole(Role) role_dao: CRUDRole = CRUDRole(Role)
+60 -81
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import bcrypt import bcrypt
from sqlalchemy import and_, desc, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload from sqlalchemy.orm import noload, selectinload
from sqlalchemy.sql import Select from sqlalchemy.sql import Select
@@ -10,11 +10,9 @@ from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept, Role, User from backend.app.admin.model import Dept, Role, User
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddOAuth2UserParam,
AddUserParam, AddUserParam,
AvatarParam,
RegisterUserParam,
UpdateUserParam, UpdateUserParam,
UpdateUserRoleParam,
) )
from backend.common.security.jwt import get_hash_password from backend.common.security.jwt import get_hash_password
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -63,26 +61,6 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username) return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
async def create(self, db: AsyncSession, obj: RegisterUserParam, *, social: bool = False) -> None:
"""
创建用户
:param db: 数据库会话
:param obj: 注册用户参数
:param social: 是否社交用户
:return:
"""
if not social:
salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': salt})
else:
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
db.add(new_user)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
添加用户 添加用户
@@ -97,14 +75,33 @@ class CRUDUser(CRUDPlus[User]):
dict_obj.update({'salt': salt}) dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
role_list = [] stmt = select(Role).where(Role.id.in_(obj.roles))
for role_id in obj.roles: roles = await db.execute(stmt)
role_list.append(await db.get(Role, role_id)) new_user.roles = roles.scalars().all()
new_user.roles.extend(role_list)
db.add(new_user) db.add(new_user)
async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int: async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
"""
通过 OAuth2 添加用户
:param db: 数据库会话
:param obj: 注册用户参数
:return:
"""
salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': salt})
new_user = self.model(**dict_obj)
stmt = select(Role)
role = await db.execute(stmt)
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
db.add(new_user)
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
""" """
更新用户信息 更新用户信息
@@ -113,36 +110,25 @@ class CRUDUser(CRUDPlus[User]):
:param obj: 更新用户参数 :param obj: 更新用户参数
:return: :return:
""" """
return await self.update_model(db, input_user, obj) role_ids = obj.roles
del obj.roles
count = await self.update_model(db, input_user.id, obj)
@staticmethod stmt = select(Role).where(Role.id.in_(role_ids))
async def update_role(db: AsyncSession, input_user: User, obj: UpdateUserRoleParam) -> None: roles = await db.execute(stmt)
""" input_user.roles = roles.scalars().all()
更新用户角色 return count
:param db: 数据库会话 async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
:param input_user: 用户对象
:param obj: 更新角色参数
:return:
"""
for i in list(input_user.roles):
input_user.roles.remove(i)
role_list = []
for role_id in obj.roles:
role_list.append(await db.get(Role, role_id))
input_user.roles.extend(role_list)
async def update_avatar(self, db: AsyncSession, input_user: int, avatar: AvatarParam) -> int:
""" """
更新用户头像 更新用户头像
:param db: 数据库会话 :param db: 数据库会话
:param input_user: 用户 ID :param user_id: 用户 ID
:param avatar: 头像地址 :param avatar: 头像地址
:return: :return:
""" """
return await self.update_model(db, input_user, {'avatar': str(avatar.url)}) return await self.update_model(db, user_id, {'avatar': avatar})
async def delete(self, db: AsyncSession, user_id: int) -> int: async def delete(self, db: AsyncSession, user_id: int) -> int:
""" """
@@ -156,7 +142,7 @@ class CRUDUser(CRUDPlus[User]):
async def check_email(self, db: AsyncSession, email: str) -> User | None: async def check_email(self, db: AsyncSession, email: str) -> User | None:
""" """
检查邮箱是否已被注册 检查邮箱是否已被绑定
:param db: 数据库会话 :param db: 数据库会话
:param email: 电子邮箱 :param email: 电子邮箱
@@ -185,30 +171,26 @@ class CRUDUser(CRUDPlus[User]):
:param status: 用户状态 :param status: 用户状态
:return: :return:
""" """
stmt = ( filters = {}
select(self.model)
.options(
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
noload(self.model.socials),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.rules)),
)
.order_by(desc(self.model.join_time))
)
filters = []
if dept: if dept:
filters.append(self.model.dept_id == dept) filters['dept_id'] = dept
if username: if username:
filters.append(self.model.username.like(f'%{username}%')) filters['username__like'] = f'%{username}%'
if phone: if phone:
filters.append(self.model.phone.like(f'%{phone}%')) filters['phone_like'] = f'%{phone}%'
if status is not None: if status is not None:
filters.append(self.model.status == status) filters['status'] = status
if filters: return await self.select_order(
stmt = stmt.where(and_(*filters)) 'id',
'desc',
return stmt load_options=[
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
],
**filters,
)
async def get_super(self, db: AsyncSession, user_id: int) -> bool: async def get_super(self, db: AsyncSession, user_id: int) -> bool:
""" """
@@ -309,22 +291,19 @@ class CRUDUser(CRUDPlus[User]):
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
stmt = select(self.model).options( filters = {}
selectinload(self.model.dept),
selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)),
)
filters = []
if user_id: if user_id:
filters.append(self.model.id == user_id) filters['id'] = user_id
if username: if username:
filters.append(self.model.username == username) filters['username'] = username
if filters: return await self.select_model_by_column(
stmt = stmt.where(and_(*filters)) db,
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
user = await db.execute(stmt) load_strategies=['dept'],
return user.scalars().first() **filters,
)
user_dao: CRUDUser = CRUDUser(User) user_dao: CRUDUser = CRUDUser(User)
-1
View File
@@ -8,4 +8,3 @@ from backend.app.admin.model.menu import Menu
from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.opera_log import OperaLog
from backend.app.admin.model.role import Role from backend.app.admin.model.role import Role
from backend.app.admin.model.user import User from backend.app.admin.model.user import User
from backend.app.admin.model.user_social import UserSocial
+4 -6
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_data_scope_rule
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -28,8 +29,5 @@ class DataRule(Base):
) )
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 数据范围规则对多 # 数据范围规则对多
scope_id: Mapped[int | None] = mapped_column( scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
ForeignKey('sys_data_scope.id', ondelete='SET NULL'), default=None, comment='数据范围关联 ID'
)
scope: Mapped[DataScope] = relationship(init=False, back_populates='rules')
+3 -3
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_scope from backend.app.admin.model.m2m import sys_data_scope_rule, sys_role_data_scope
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -23,8 +23,8 @@ class DataScope(Base):
name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称') name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则对多 # 数据范围规则对多
rules: Mapped[list[DataRule]] = relationship(init=False, back_populates='scope') rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='scopes')
# 角色数据范围多对多 # 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes') roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, String from sqlalchemy import BigInteger, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER from sqlalchemy.dialects.postgresql import INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -32,7 +32,7 @@ class Dept(Base):
# 父级部门一对多 # 父级部门一对多
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID' BigInteger, ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
) )
parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
+30 -10
View File
@@ -1,35 +1,55 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from sqlalchemy import INT, Column, ForeignKey, Integer, Table from sqlalchemy import BigInteger, Column, ForeignKey, Table
from backend.common.model import MappedBase from backend.common.model import MappedBase
sys_user_role = Table( sys_user_role = Table(
'sys_user_role', 'sys_user_role',
MappedBase.metadata, MappedBase.metadata,
Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('user_id', Integer, ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'), Column('user_id', BigInteger, ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'),
Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
) )
sys_role_menu = Table( sys_role_menu = Table(
'sys_role_menu', 'sys_role_menu',
MappedBase.metadata, MappedBase.metadata,
Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
Column('menu_id', Integer, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'), Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
) )
sys_role_data_scope = Table( sys_role_data_scope = Table(
'sys_role_data_scope', 'sys_role_data_scope',
MappedBase.metadata, MappedBase.metadata,
Column('id', INT, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'), Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
Column('role_id', Integer, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'), Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
Column( Column(
'data_scope_id', 'data_scope_id',
Integer, BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'), ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True, primary_key=True,
comment='数据范围 ID', comment='数据范围 ID',
), ),
) )
sys_data_scope_rule = Table(
'sys_data_scope_rule',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column(
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
Column(
'data_rule_id',
BigInteger,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
primary_key=True,
comment='数据规则 ID',
),
)
+4 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -24,10 +24,10 @@ class Menu(Base):
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='菜单标题') title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
name: Mapped[str] = mapped_column(String(50), comment='菜单名称') name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
path: Mapped[str] = mapped_column(String(200), comment='路由地址') path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序') sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标') icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)') type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链')
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径') component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识') perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
@@ -42,7 +42,7 @@ class Menu(Base):
# 父级菜单一对多 # 父级菜单一对多
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID' BigInteger, ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
) )
parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent')
+5 -2
View File
@@ -4,9 +4,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import Boolean, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
@@ -24,6 +24,9 @@ class Role(Base):
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称') name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=True, comment='过滤数据权限(0否 1是)'
)
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
) )
+8 -11
View File
@@ -15,7 +15,7 @@ from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role, UserSocial from backend.app.admin.model import Dept, Role
class User(Base): class User(Base):
@@ -26,22 +26,22 @@ class User(Base):
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True) uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名') username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(String(20), unique=True, comment='昵称') nickname: Mapped[str] = mapped_column(String(20), comment='昵称')
password: Mapped[str | None] = mapped_column(String(255), comment='密码') password: Mapped[str] = mapped_column(String(255), comment='密码')
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐') salt: Mapped[bytes] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐')
email: Mapped[str] = mapped_column(String(50), unique=True, index=True, comment='邮箱') email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像')
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_superuser: Mapped[bool] = mapped_column( is_superuser: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)'
) )
is_staff: Mapped[bool] = mapped_column( is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)'
) )
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_multi_login: Mapped[bool] = mapped_column( is_multi_login: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)'
) )
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
join_time: Mapped[datetime] = mapped_column( join_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='注册时间' DateTime(timezone=True), init=False, default_factory=timezone.now, comment='注册时间'
) )
@@ -55,8 +55,5 @@ class User(Base):
) )
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users') dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户社交信息一对多
socials: Mapped[list[UserSocial]] = relationship(init=False, back_populates='user')
# 用户角色多对多 # 用户角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users') roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class UserSocial(Base):
"""用户社交表(OAuth2"""
__tablename__ = 'sys_user_social'
id: Mapped[id_key] = mapped_column(init=False)
source: Mapped[str] = mapped_column(String(20), comment='第三方用户来源')
open_id: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 open id')
uid: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 ID')
union_id: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 union id')
scope: Mapped[str | None] = mapped_column(String(120), default=None, comment='第三方用户授予的权限')
code: Mapped[str | None] = mapped_column(String(50), default=None, comment='用户的授权 code')
# 用户社交信息一对多
user_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_user.id', ondelete='SET NULL'), default=None, comment='用户关联ID'
)
user: Mapped[User | None] = relationship(init=False, back_populates='socials')
+6
View File
@@ -27,6 +27,12 @@ class UpdateDataRuleParam(DataRuleSchemaBase):
"""更新数据规则参数""" """更新数据规则参数"""
class DeleteDataRuleParam(SchemaBase):
"""删除数据规则参数"""
pks: list[int] = Field(description='规则 ID 列表')
class GetDataRuleDetail(DataRuleSchemaBase): class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情""" """数据规则详情"""
+6
View File
@@ -30,6 +30,12 @@ class UpdateDataScopeRuleParam(SchemaBase):
rules: list[int] = Field(description='数据规则 ID 列表') rules: list[int] = Field(description='数据规则 ID 列表')
class DeleteDataScopeParam(SchemaBase):
"""删除数据范围参数"""
pks: list[int] = Field(description='数据范围 ID 列表')
class GetDataScopeDetail(DataScopeBase): class GetDataScopeDetail(DataScopeBase):
"""数据范围详情""" """数据范围详情"""
+6
View File
@@ -33,6 +33,12 @@ class UpdateLoginLogParam(LoginLogSchemaBase):
"""更新登录日志参数""" """更新登录日志参数"""
class DeleteLoginLogParam(SchemaBase):
"""删除登录日志参数"""
pks: list[int] = Field(description='登录日志 ID 列表')
class GetLoginLogDetail(LoginLogSchemaBase): class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情""" """登录日志详情"""
+2 -2
View File
@@ -13,11 +13,11 @@ class MenuSchemaBase(SchemaBase):
title: str = Field(description='菜单标题') title: str = Field(description='菜单标题')
name: str = Field(description='菜单名称') name: str = Field(description='菜单名称')
path: str = Field(description='路由路径') path: str | None = Field(None, description='路由地址')
parent_id: int | None = Field(None, description='菜单父级 ID') parent_id: int | None = Field(None, description='菜单父级 ID')
sort: int = Field(0, ge=0, description='排序') sort: int = Field(0, ge=0, description='排序')
icon: str | None = Field(None, description='图标') icon: str | None = Field(None, description='图标')
type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)') type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链')
component: str | None = Field(None, description='组件路径') component: str | None = Field(None, description='组件路径')
perms: str | None = Field(None, description='权限标识') perms: str | None = Field(None, description='权限标识')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(StatusType.enable, description='状态')
+6
View File
@@ -41,6 +41,12 @@ class UpdateOperaLogParam(OperaLogSchemaBase):
"""更新操作日志参数""" """更新操作日志参数"""
class DeleteOperaLogParam(SchemaBase):
"""删除操作日志参数"""
pks: list[int] = Field(description='操作日志 ID 列表')
class GetOperaLogDetail(OperaLogSchemaBase): class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情""" """操作日志详情"""
+7
View File
@@ -15,6 +15,7 @@ class RoleSchemaBase(SchemaBase):
name: str = Field(description='角色名称') name: str = Field(description='角色名称')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(StatusType.enable, description='状态')
is_filter_scopes: bool = Field(True, description='过滤数据权限')
remark: str | None = Field(None, description='备注') remark: str | None = Field(None, description='备注')
@@ -26,6 +27,12 @@ class UpdateRoleParam(RoleSchemaBase):
"""更新角色参数""" """更新角色参数"""
class DeleteRoleParam(SchemaBase):
"""删除角色参数"""
pks: list[int] = Field(description='角色 ID 列表')
class UpdateRoleMenuParam(SchemaBase): class UpdateRoleMenuParam(SchemaBase):
"""更新角色菜单参数""" """更新角色菜单参数"""
-6
View File
@@ -35,12 +35,6 @@ class GetLoginToken(AccessTokenBase):
user: GetUserInfoDetail = Field(description='用户信息') user: GetUserInfoDetail = Field(description='用户信息')
class KickOutToken(SchemaBase):
"""踢出令牌"""
session_uuid: str = Field(description='会话 UUID')
class GetTokenDetail(SchemaBase): class GetTokenDetail(SchemaBase):
"""令牌详情""" """令牌详情"""
+12 -22
View File
@@ -9,7 +9,7 @@ from typing_extensions import Self
from backend.app.admin.schema.dept import GetDeptDetail from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleWithRelationDetail from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import CustomPhoneNumber, SchemaBase from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
class AuthSchemaBase(SchemaBase): class AuthSchemaBase(SchemaBase):
@@ -25,20 +25,20 @@ class AuthLoginParam(AuthSchemaBase):
captcha: str = Field(description='验证码') captcha: str = Field(description='验证码')
class RegisterUserParam(AuthSchemaBase):
"""用户注册参数"""
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class AddUserParam(AuthSchemaBase): class AddUserParam(AuthSchemaBase):
"""添加用户参数""" """添加用户参数"""
dept_id: int = Field(description='部门 ID') dept_id: int = Field(description='部门 ID')
roles: list[int] = Field(description='角色 ID 列表') roles: list[int] = Field(description='角色 ID 列表')
nickname: str | None = Field(None, description='昵称') nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class AddOAuth2UserParam(AuthSchemaBase):
"""添加 OAuth2 用户参数"""
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(description='邮箱')
avatar: HttpUrl | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase): class ResetPasswordParam(SchemaBase):
@@ -55,26 +55,15 @@ class UserInfoSchemaBase(SchemaBase):
dept_id: int | None = Field(None, description='部门 ID') dept_id: int | None = Field(None, description='部门 ID')
username: str = Field(description='用户名') username: str = Field(description='用户名')
nickname: str = Field(description='昵称') nickname: str = Field(description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱') avatar: HttpUrl | None = Field(None, description='头像地址')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
class UpdateUserParam(UserInfoSchemaBase): class UpdateUserParam(UserInfoSchemaBase):
"""更新用户参数""" """更新用户参数"""
class UpdateUserRoleParam(SchemaBase):
"""更新用户角色参数"""
roles: list[int] = Field(description='角色 ID 列表') roles: list[int] = Field(description='角色 ID 列表')
class AvatarParam(SchemaBase):
"""更新头像参数"""
url: HttpUrl = Field(description='头像 http 地址')
class GetUserInfoDetail(UserInfoSchemaBase): class GetUserInfoDetail(UserInfoSchemaBase):
"""用户信息详情""" """用户信息详情"""
@@ -83,7 +72,8 @@ class GetUserInfoDetail(UserInfoSchemaBase):
dept_id: int | None = Field(None, description='部门 ID') dept_id: int | None = Field(None, description='部门 ID')
id: int = Field(description='用户 ID') id: int = Field(description='用户 ID')
uuid: str = Field(description='用户 UUID') uuid: str = Field(description='用户 UUID')
avatar: str | None = Field(None, description='头像') email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(StatusType.enable, description='状态')
is_superuser: bool = Field(description='是否超级管理员') is_superuser: bool = Field(description='是否超级管理员')
is_staff: bool = Field(description='是否管理员') is_staff: bool = Field(description='是否管理员')
+61 -39
View File
@@ -5,7 +5,7 @@ from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.conf import admin_settings from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken
@@ -67,13 +67,13 @@ class AuthService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
a_token = await create_access_token( access_token = await create_access_token(
str(user.id), user.id,
user.is_multi_login, user.is_multi_login,
# extra info # extra info
swagger=True, swagger=True,
) )
return a_token.access_token, user return access_token.access_token, user
async def login( async def login(
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
@@ -91,38 +91,38 @@ class AuthService:
user = None user = None
try: try:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code: if not captcha_code:
raise errors.AuthorizationError(msg='验证码失效,请重新获取') raise errors.RequestError(msg='验证码失效,请重新获取')
if captcha_code.lower() != obj.captcha.lower(): if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await db.refresh(user)
a_token = await create_access_token( access_token = await create_access_token(
str(user.id), user.id,
user.is_multi_login, user.is_multi_login,
# extra info # extra info
username=user.username, username=user.username,
nickname=user.nickname, nickname=user.nickname,
last_login_time=timezone.t_str(user.last_login_time), last_login_time=timezone.to_str(user.last_login_time),
ip=request.state.ip, ip=request.state.ip,
os=request.state.os, os=request.state.os,
browser=request.state.browser, browser=request.state.browser,
device=request.state.device, device=request.state.device,
) )
r_token = await create_refresh_token(str(user.id), user.is_multi_login) refresh_token = await create_refresh_token(access_token.session_uuid, user.id, user.is_multi_login)
response.set_cookie( response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY, key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=r_token.refresh_token, value=refresh_token.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS, max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.f_utc(r_token.refresh_token_expire_time), expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
httponly=True, httponly=True,
) )
except errors.NotFoundError as e: except errors.NotFoundError as e:
log.error('登陆错误: 用户名不存在') log.error('登陆错误: 用户名不存在')
raise errors.NotFoundError(msg=e.msg) raise errors.NotFoundError(msg=e.msg)
except (errors.AuthorizationError, errors.CustomError) as e: except (errors.RequestError, errors.CustomError) as e:
if not user: if not user:
log.error('登陆错误: 用户密码有误') log.error('登陆错误: 用户密码有误')
task = BackgroundTask( task = BackgroundTask(
@@ -137,7 +137,7 @@ class AuthService:
msg=e.msg, msg=e.msg,
), ),
) )
raise errors.AuthorizationError(msg=e.msg, background=task) raise errors.RequestError(msg=e.msg, background=task)
except Exception as e: except Exception as e:
log.error(f'登陆错误: {e}') log.error(f'登陆错误: {e}')
raise e raise e
@@ -155,17 +155,42 @@ class AuthService:
), ),
) )
data = GetLoginToken( data = GetLoginToken(
access_token=a_token.access_token, access_token=access_token.access_token,
access_token_expire_time=a_token.access_token_expire_time, access_token_expire_time=access_token.access_token_expire_time,
session_uuid=a_token.session_uuid, session_uuid=access_token.session_uuid,
user=user, # type: ignore user=user, # type: ignore
) )
return data return data
@staticmethod @staticmethod
async def new_token(*, request: Request) -> GetNewToken: async def get_codes(*, request: Request) -> list[str]:
""" """
获取新的访问令牌 获取用户权限码
:param request: FastAPI 请求对象
:return:
"""
codes = set()
if request.user.is_superuser:
async with async_db_session.begin() as db:
menus = await menu_dao.get_all(db, None, None)
for menu in menus:
if menu.perms:
codes.add(*menu.perms.split(','))
else:
roles = request.user.roles
if roles:
for role in roles:
for menu in role.menus:
if menu.perms:
codes.add(*menu.perms.split(','))
return list(codes)
@staticmethod
async def refresh_token(*, request: Request) -> GetNewToken:
"""
刷新令牌
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
@@ -173,24 +198,22 @@ class AuthService:
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if not refresh_token: if not refresh_token:
raise errors.TokenError(msg='Refresh Token 已过期,请重新登录') raise errors.TokenError(msg='Refresh Token 已过期,请重新登录')
try: token_payload = jwt_decode(refresh_token)
user_id = jwt_decode(refresh_token).id
except Exception:
raise errors.TokenError(msg='Refresh Token 无效')
async with async_db_session() as db: async with async_db_session() as db:
user = await user_dao.get(db, user_id) user = await user_dao.get(db, token_payload.id)
if not user: if not user:
raise errors.NotFoundError(msg='用户名或密码有误') raise errors.NotFoundError(msg='用户不存在')
elif not user.status: elif not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
new_token = await create_new_token( new_token = await create_new_token(
user_id=str(user.id), refresh_token,
refresh_token=refresh_token, token_payload.session_uuid,
multi_login=user.is_multi_login, user.id,
user.is_multi_login,
# extra info # extra info
username=user.username, username=user.username,
nickname=user.nickname, nickname=user.nickname,
last_login_time=timezone.t_str(user.last_login_time), last_login_time=timezone.to_str(user.last_login_time),
ip=request.state.ip, ip=request.state.ip,
os=request.state.os, os=request.state.os,
browser=request.state.browser, browser=request.state.browser,
@@ -212,22 +235,21 @@ class AuthService:
:param response: FastAPI 响应对象 :param response: FastAPI 响应对象
:return: :return:
""" """
try:
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id user_id = token_payload.id
session_uuid = token_payload.session_uuid
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError:
return
finally:
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY) response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
if request.user.is_multi_login:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if refresh_token: if refresh_token:
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}') await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
else:
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
auth_service: AuthService = AuthService() auth_service: AuthService = AuthService()
+12 -7
View File
@@ -6,7 +6,12 @@ from sqlalchemy import Select
from backend.app.admin.crud.crud_data_rule import data_rule_dao from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.model import DataRule from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleColumnDetail, UpdateDataRuleParam from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
UpdateDataRuleParam,
)
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -82,7 +87,7 @@ class DataRuleService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
data_rule = await data_rule_dao.get_by_name(db, obj.name) data_rule = await data_rule_dao.get_by_name(db, obj.name)
if data_rule: if data_rule:
raise errors.ForbiddenError(msg='数据规则已存在') raise errors.ConflictError(msg='数据规则已存在')
await data_rule_dao.create(db, obj) await data_rule_dao.create(db, obj)
@staticmethod @staticmethod
@@ -100,20 +105,20 @@ class DataRuleService:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name: if data_rule.name != obj.name:
if await data_rule_dao.get_by_name(db, obj.name): if await data_rule_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据规则已存在') raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj) count = await data_rule_dao.update(db, pk, obj)
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: DeleteDataRuleParam) -> int:
""" """
删除数据规则 批量删除数据规则
:param pk: 规则 ID 列表 :param obj: 规则 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await data_rule_dao.delete(db, pk) count = await data_rule_dao.delete(db, obj.pks)
return count return count
@@ -1,10 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.model import DataScope from backend.app.admin.model import DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -28,6 +35,13 @@ class DataScopeService:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
return data_scope return data_scope
@staticmethod
async def get_all() -> Sequence[DataScope]:
"""获取所有数据范围"""
async with async_db_session() as db:
data_scopes = await data_scope_dao.get_all(db)
return data_scopes
@staticmethod @staticmethod
async def get_rules(*, pk: int) -> DataScope: async def get_rules(*, pk: int) -> DataScope:
""" """
@@ -64,7 +78,7 @@ class DataScopeService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get_by_name(db, obj.name) data_scope = await data_scope_dao.get_by_name(db, obj.name)
if data_scope: if data_scope:
raise errors.ForbiddenError(msg='数据范围已存在') raise errors.ConflictError(msg='数据范围已存在')
await data_scope_dao.create(db, obj) await data_scope_dao.create(db, obj)
@staticmethod @staticmethod
@@ -82,7 +96,7 @@ class DataScopeService:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name: if data_scope.name != obj.name:
if await data_scope_dao.get_by_name(db, obj.name): if await data_scope_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据范围已存在') raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj) count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.awaitable_attrs.roles: for role in await data_scope.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
@@ -103,17 +117,17 @@ class DataScopeService:
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: DeleteDataScopeParam) -> int:
""" """
删除数据范围 批量删除数据范围
:param pk: 范围 ID 列表 :param obj: 范围 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await data_scope_dao.delete(db, pk) count = await data_scope_dao.delete(db, obj.pks)
for _pk in pk: for pk in obj.pks:
data_rule = await data_scope_dao.get(db, _pk) data_rule = await data_scope_dao.get(db, pk)
if data_rule: if data_rule:
for role in await data_rule.awaitable_attrs.roles: for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
+5 -5
View File
@@ -32,7 +32,7 @@ class DeptService:
return dept return dept
@staticmethod @staticmethod
async def get_dept_tree( async def get_tree(
*, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None *, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
@@ -61,7 +61,7 @@ class DeptService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept = await dept_dao.get_by_name(db, obj.name) dept = await dept_dao.get_by_name(db, obj.name)
if dept: if dept:
raise errors.ForbiddenError(msg='部门名称已存在') raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id: if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id) parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept: if not parent_dept:
@@ -83,7 +83,7 @@ class DeptService:
raise errors.NotFoundError(msg='部门不存在') raise errors.NotFoundError(msg='部门不存在')
if dept.name != obj.name: if dept.name != obj.name:
if await dept_dao.get_by_name(db, obj.name): if await dept_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='部门名称已存在') raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id: if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id) parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept: if not parent_dept:
@@ -104,10 +104,10 @@ class DeptService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept = await dept_dao.get_with_relation(db, pk) dept = await dept_dao.get_with_relation(db, pk)
if dept.users: if dept.users:
raise errors.ForbiddenError(msg='部门下存在用户,无法删除') raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk) children = await dept_dao.get_children(db, pk)
if children: if children:
raise errors.ForbiddenError(msg='部门下存在子部门,无法删除') raise errors.ConflictError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk) count = await dept_dao.delete(db, pk)
for user in dept.users: for user in dept.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
@@ -7,7 +7,7 @@ from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_login_log import login_log_dao from backend.app.admin.crud.crud_login_log import login_log_dao
from backend.app.admin.schema.login_log import CreateLoginLogParam from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
from backend.common.log import log from backend.common.log import log
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -71,15 +71,15 @@ class LoginLogService:
log.error(f'登录日志创建失败: {e}') log.error(f'登录日志创建失败: {e}')
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: DeleteLoginLogParam) -> int:
""" """
删除登录日志 批量删除登录日志
:param pk: 日志 ID 列表 :param obj: 日志 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await login_log_dao.delete(db, pk) count = await login_log_dao.delete(db, obj.pks)
return count return count
@staticmethod @staticmethod
+16 -13
View File
@@ -32,7 +32,7 @@ class MenuService:
return menu return menu
@staticmethod @staticmethod
async def get_menu_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]: async def get_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]:
""" """
获取菜单树形结构 获取菜单树形结构
@@ -41,27 +41,30 @@ class MenuService:
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
menu_select = await menu_dao.get_all(db, title=title, status=status) menu_data = await menu_dao.get_all(db, title=title, status=status)
menu_tree = get_tree_data(menu_select) menu_tree = get_tree_data(menu_data)
return menu_tree return menu_tree
@staticmethod @staticmethod
async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]: async def get_sidebar(*, request: Request) -> list[dict[str, Any] | None]:
""" """
获取用户的菜单树形结构 获取用户的菜单侧边栏
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None)
else:
roles = request.user.roles roles = request.user.roles
menu_ids = [] menu_ids = set()
menu_tree = []
if roles: if roles:
for role in roles: for role in roles:
menu_ids.extend([menu.id for menu in role.menus]) for menu in role.menus:
menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids) menu_ids.add(menu.id)
menu_tree = get_vben5_tree_data(menu_select) menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
menu_tree = get_vben5_tree_data(menu_data)
return menu_tree return menu_tree
@staticmethod @staticmethod
@@ -75,7 +78,7 @@ class MenuService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
title = await menu_dao.get_by_title(db, obj.title) title = await menu_dao.get_by_title(db, obj.title)
if title: if title:
raise errors.ForbiddenError(msg='菜单标题已存在') raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id: if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id) parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu: if not parent_menu:
@@ -97,7 +100,7 @@ class MenuService:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title: if menu.title != obj.title:
if await menu_dao.get_by_title(db, obj.title): if await menu_dao.get_by_title(db, obj.title):
raise errors.ForbiddenError(msg='菜单标题已存在') raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id: if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id) parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu: if not parent_menu:
@@ -121,7 +124,7 @@ class MenuService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
children = await menu_dao.get_children(db, pk) children = await menu_dao.get_children(db, pk)
if children: if children:
raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除') raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk) menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk) count = await menu_dao.delete(db, pk)
if menu: if menu:
@@ -3,7 +3,7 @@
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.admin.crud.crud_opera_log import opera_log_dao from backend.app.admin.crud.crud_opera_log import opera_log_dao
from backend.app.admin.schema.opera_log import CreateOperaLogParam from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -34,15 +34,15 @@ class OperaLogService:
await opera_log_dao.create(db, obj) await opera_log_dao.create(db, obj)
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: DeleteOperaLogParam) -> int:
""" """
删除操作日志 批量删除操作日志
:param pk: 日志 ID 列表 :param obj: 日志 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await opera_log_dao.delete(db, pk) count = await opera_log_dao.delete(db, obj.pks)
return count return count
@staticmethod @staticmethod
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import json
import os
import re
import shutil
import zipfile
from typing import Any
from dulwich import porcelain
from fastapi import UploadFile
from backend.common.enums import PluginType, StatusType
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import install_requirements_async, uninstall_requirements_async
from backend.utils.re_verify import is_git_url
from backend.utils.timezone import timezone
class PluginService:
"""插件服务类"""
@staticmethod
async def get_all() -> list[dict[str, Any]]:
"""获取所有插件"""
keys = []
result = []
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'):
keys.append(key)
for info in await redis_client.mget(*keys):
result.append(json.loads(info))
return result
@staticmethod
async def changed() -> str | None:
"""检查插件是否发生变更"""
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
@staticmethod
async def install_zip(*, file: UploadFile) -> None:
"""
通过 zip 压缩包安装插件
:param file: 插件 zip 压缩包
:return:
"""
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.RequestError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_namelist = zf.namelist()
zip_plugin_dir = plugin_namelist[0].split('/')[0]
if not plugin_namelist:
raise errors.RequestError(msg='插件压缩包内容非法')
if (
len(plugin_namelist) <= 3
or f'{zip_plugin_dir}/plugin.toml' not in plugin_namelist
or f'{zip_plugin_dir}/README.md' not in plugin_namelist
):
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
plugin_name = re.match(r'^([a-zA-Z0-9_]+)', file.filename.split('.')[0].strip()).group()
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ConflictError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(zip_plugin_dir):
new_filename = member.filename.replace(zip_plugin_dir, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(full_plugin_path, members)
await install_requirements_async(zip_plugin_dir)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def install_git(*, repo_url: str):
"""
通过 git 安装插件
:param repo_url: git 存储库的 URL
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.RequestError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
if repo_name in plugins:
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
else:
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
async def install(self, *, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None):
"""
安装插件
:param type: 插件类型
:param file: 插件 zip 压缩包
:param repo_url: git 仓库地址
:return:
"""
if type == PluginType.zip:
if not file:
raise errors.RequestError(msg='ZIP 压缩包不能为空')
await self.install_zip(file=file)
elif type == PluginType.git:
if not repo_url:
raise errors.RequestError(msg='Git 仓库地址不能为空')
await self.install_git(repo_url=repo_url)
@staticmethod
async def uninstall(*, plugin: str):
"""
卸载插件
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
shutil.move(plugin_dir, bacup_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
await redis_client.hdel(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def update_status(*, plugin: str):
"""
更新插件状态
:param plugin: 插件名称
:return:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
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(
f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False)
)
await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, new_status)
@staticmethod
async def build(*, plugin: str) -> io.BytesIO:
"""
打包插件为 zip 压缩包
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, os.path.join(plugin, arcname))
bio.seek(0)
return bio
plugin_service: PluginService = PluginService()
+13 -27
View File
@@ -10,6 +10,7 @@ from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.model import Role from backend.app.admin.model import Role
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
DeleteRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleScopeParam, UpdateRoleScopeParam,
@@ -45,18 +46,6 @@ class RoleService:
roles = await role_dao.get_all(db) roles = await role_dao.get_all(db)
return roles return roles
@staticmethod
async def get_users(*, pk: int) -> Sequence[Role]:
"""
获取用户的角色列表
:param pk: 用户 ID
:return:
"""
async with async_db_session() as db:
roles = await role_dao.get_users(db, user_id=pk)
return roles
@staticmethod @staticmethod
async def get_select(*, name: str | None, status: int | None) -> Select: async def get_select(*, name: str | None, status: int | None) -> Select:
""" """
@@ -69,7 +58,7 @@ class RoleService:
return await role_dao.get_list(name=name, status=status) return await role_dao.get_list(name=name, status=status)
@staticmethod @staticmethod
async def get_menu_tree(*, pk: int) -> list[dict[str, Any]]: async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]:
""" """
获取角色的菜单树形结构 获取角色的菜单树形结构
@@ -80,9 +69,7 @@ class RoleService:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_with_relation(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
menu_ids = [menu.id for menu in role.menus] menu_tree = get_tree_data(role.menus) if role.menus else []
menu_select = await menu_dao.get_role_menus(db, False, menu_ids)
menu_tree = get_tree_data(menu_select)
return menu_tree return menu_tree
@staticmethod @staticmethod
@@ -111,7 +98,7 @@ class RoleService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get_by_name(db, obj.name) role = await role_dao.get_by_name(db, obj.name)
if role: if role:
raise errors.ForbiddenError(msg='角色已存在') raise errors.ConflictError(msg='角色已存在')
await role_dao.create(db, obj) await role_dao.create(db, obj)
@staticmethod @staticmethod
@@ -128,9 +115,8 @@ class RoleService:
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name: if role.name != obj.name:
role = await role_dao.get_by_name(db, obj.name) if await role_dao.get_by_name(db, obj.name):
if role: raise errors.ConflictError(msg='角色已存在')
raise errors.ForbiddenError(msg='角色已存在')
count = await role_dao.update(db, pk, obj) count = await role_dao.update(db, pk, obj)
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
@@ -146,7 +132,7 @@ class RoleService:
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
for menu_id in menu_ids.menus: for menu_id in menu_ids.menus:
@@ -181,17 +167,17 @@ class RoleService:
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: DeleteRoleParam) -> int:
""" """
删除角色 批量删除角色
:param pk: 角色 ID 列表 :param obj: 角色 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await role_dao.delete(db, pk) count = await role_dao.delete(db, obj.pks)
for _pk in pk: for pk in obj.pks:
role = await role_dao.get(db, _pk) role = await role_dao.get(db, pk)
if role: if role:
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
+122 -166
View File
@@ -2,21 +2,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import random import random
from typing import Sequence
from fastapi import Request from fastapi import Request
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.admin.crud.crud_dept import dept_dao 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_role import role_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import Role, User
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
AvatarParam,
RegisterUserParam,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
UpdateUserRoleParam,
) )
from backend.common.enums import UserPermissionType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.security.jwt import get_hash_password, get_token, jwt_decode, password_verify, superuser_verify from backend.common.security.jwt import get_hash_password, get_token, jwt_decode, password_verify, superuser_verify
from backend.core.conf import settings from backend.core.conf import settings
@@ -28,176 +28,33 @@ class UserService:
"""用户服务类""" """用户服务类"""
@staticmethod @staticmethod
async def register(*, obj: RegisterUserParam) -> None: async def get_userinfo(*, pk: int | None = None, username: str | None = None) -> User:
"""
注册新用户
:param obj: 用户注册参数
:return:
"""
async with async_db_session.begin() as db:
if not obj.password:
raise errors.ForbiddenError(msg='密码为空')
username = await user_dao.get_by_username(db, obj.username)
if username:
raise errors.ForbiddenError(msg='用户已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(10000, 88888)}'
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
await user_dao.create(db, obj)
@staticmethod
async def add(*, request: Request, obj: AddUserParam) -> None:
"""
添加新用户
:param request: FastAPI 请求对象
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
username = await user_dao.get_by_username(db, obj.username)
if username:
raise errors.ForbiddenError(msg='用户已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
if not obj.password:
raise errors.ForbiddenError(msg='密码为空')
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
dept = await dept_dao.get(db, obj.dept_id)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
role = await role_dao.get(db, role_id)
if not role:
raise errors.NotFoundError(msg='角色不存在')
await user_dao.add(db, obj)
@staticmethod
async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int:
"""
重置用户密码
:param request: FastAPI 请求对象
:param obj: 密码重置参数
:return:
"""
async with async_db_session.begin() as db:
user = await user_dao.get(db, request.user.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.ForbiddenError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.ForbiddenError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, request.user.id, new_pwd)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
@staticmethod
async def get_userinfo(*, username: str) -> User:
""" """
获取用户信息 获取用户信息
:param pk: 用户 ID
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
async with async_db_session() as db: async with async_db_session() as db:
user = await user_dao.get_with_relation(db, username=username) user = await user_dao.get_with_relation(db, user_id=pk, username=username)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
return user return user
@staticmethod @staticmethod
async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int: async def get_roles(*, pk: int) -> Sequence[Role]:
""" """
更新用户信息 获取用户所有角色
:param request: FastAPI 请求对象 :param pk: 用户 ID
:param username: 用户名
:param obj: 用户更新参数
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session() as db:
if request.user.username != username: user = await user_dao.get_with_relation(db, user_id=pk)
raise errors.ForbiddenError(msg='你只能修改自己的信息')
user = await user_dao.get_with_relation(db, username=username)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if user.username != obj.username: return user.roles
_username = await user_dao.get_by_username(db, obj.username)
if _username:
raise errors.ForbiddenError(msg='用户名已注册')
if user.nickname != obj.nickname:
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
if user.email != obj.email:
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
count = await user_dao.update_userinfo(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_roles(*, request: Request, username: str, obj: UpdateUserRoleParam) -> None:
"""
更新用户角色
:param request: FastAPI 请求对象
:param username: 用户名
:param obj: 角色更新参数
:return:
"""
async with async_db_session.begin() as db:
if not request.user.is_superuser and request.user.username != username:
raise errors.ForbiddenError(msg='你只能修改自己的信息')
input_user = await user_dao.get_with_relation(db, username=username)
if not input_user:
raise errors.NotFoundError(msg='用户不存在')
for role_id in obj.roles:
role = await role_dao.get(db, role_id)
if not role:
raise errors.NotFoundError(msg='角色不存在')
await user_dao.update_role(db, input_user, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{input_user.id}')
@staticmethod
async def update_avatar(*, request: Request, username: str, avatar: AvatarParam) -> int:
"""
更新用户头像
:param request: FastAPI 请求对象
:param username: 用户名
:param avatar: 头像参数
:return:
"""
async with async_db_session.begin() as db:
if request.user.username != username:
raise errors.AuthorizationError
user = await user_dao.get_by_username(db, username)
if not user:
raise errors.NotFoundError(msg='用户不存在')
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 @staticmethod
async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select: async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select:
@@ -213,9 +70,58 @@ class UserService:
return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status) return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status)
@staticmethod @staticmethod
async def update_permission(*, request: Request, pk: int) -> int: async def create(*, request: Request, obj: AddUserParam) -> None:
""" """
更新用户权限 创建用户
:param request: FastAPI 请求对象
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
if not obj.password:
raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
await user_dao.add(db, obj)
@staticmethod
async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param request: FastAPI 请求对象
:param pk: 用户 ID
:param obj: 用户更新参数
:return:
"""
async with async_db_session.begin() as db:
user = await user_dao.get_with_relation(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if request.user.username != user.username:
raise errors.ForbiddenError(msg='只能修改自己的信息')
if obj.username != user.username:
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_superuser(*, request: Request, pk: int) -> int:
"""
更新用户管理员状态
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:param pk: 用户 ID :param pk: 用户 ID
@@ -227,7 +133,7 @@ class UserService:
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='非法操作') raise errors.ForbiddenError(msg='禁止修改自身权限')
super_status = await user_dao.get_super(db, pk) super_status = await user_dao.get_super(db, pk)
count = await user_dao.set_super(db, pk, not super_status) count = await user_dao.set_super(db, pk, not super_status)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
@@ -248,7 +154,7 @@ class UserService:
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='非法操作') raise errors.ForbiddenError(msg='禁止修改自身权限')
staff_status = await user_dao.get_staff(db, pk) staff_status = await user_dao.get_staff(db, pk)
count = await user_dao.set_staff(db, pk, not staff_status) count = await user_dao.set_staff(db, pk, not staff_status)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
@@ -269,7 +175,7 @@ class UserService:
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='非法操作') raise errors.ForbiddenError(msg='禁止修改自身权限')
status = await user_dao.get_status(db, pk) status = await user_dao.get_status(db, pk)
count = await user_dao.set_status(db, pk, 0 if status == 1 else 1) count = await user_dao.set_status(db, pk, 0 if status == 1 else 1)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
@@ -307,16 +213,66 @@ class UserService:
await redis_client.delete_prefix(key_prefix) await redis_client.delete_prefix(key_prefix)
return count return count
@staticmethod async def update_permission(self, *, request: Request, pk: int, type: UserPermissionType) -> int:
async def delete(*, username: str) -> int:
""" """
删除用户 更新用户权限
:param username: 用户名 :param request: FastAPI 请求对象
:param pk: 用户 ID
:param type: 权限类型
:return:
"""
match type:
case UserPermissionType.superuser:
count = await self.update_superuser(request=request, pk=pk)
case UserPermissionType.staff:
count = await self.update_staff(request=request, pk=pk)
case UserPermissionType.status:
count = await self.update_status(request=request, pk=pk)
case UserPermissionType.multi_login:
count = await self.update_multi_login(request=request, pk=pk)
case _:
raise errors.RequestError(msg='权限类型不存在')
return count
@staticmethod
async def reset_pwd(*, pk: int, obj: ResetPasswordParam) -> int:
"""
重置用户密码
:param pk: 用户 ID
:param obj: 密码重置参数
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = await user_dao.get_by_username(db, username) user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, user.id, new_pwd)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
@staticmethod
async def delete(*, pk: int) -> int:
"""
删除用户
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db:
user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.delete(db, user.id) count = await user_dao.delete(db, user.id)
+11 -11
View File
@@ -14,12 +14,6 @@ from backend.common.security.rbac import DependsRBAC
router = APIRouter() router = APIRouter()
@router.get('', summary='获取可执行任务', dependencies=[DependsJwtAuth])
async def get_all_tasks() -> ResponseSchemaModel[list[str]]:
tasks = await task_service.get_list()
return response_base.success(data=tasks)
@router.get( @router.get(
'/{tid}', '/{tid}',
summary='获取任务详情', summary='获取任务详情',
@@ -27,12 +21,18 @@ async def get_all_tasks() -> ResponseSchemaModel[list[str]]:
description='此接口被视为作废,建议使用 flower 查看任务详情', description='此接口被视为作废,建议使用 flower 查看任务详情',
dependencies=[DependsJwtAuth], dependencies=[DependsJwtAuth],
) )
async def get_task_detail(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseSchemaModel[TaskResult]: async def get_task(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseSchemaModel[TaskResult]:
status = task_service.get_detail(tid=tid) status = task_service.get(tid=tid)
return response_base.success(data=status) return response_base.success(data=status)
@router.post( @router.get('', summary='获取所有任务', dependencies=[DependsJwtAuth])
async def get_all_tasks() -> ResponseSchemaModel[list[str]]:
tasks = await task_service.get_all()
return response_base.success(data=tasks)
@router.delete(
'/{tid}', '/{tid}',
summary='撤销任务', summary='撤销任务',
dependencies=[ dependencies=[
@@ -46,8 +46,8 @@ async def revoke_task(tid: Annotated[str, Path(description='任务 UUID')]) -> R
@router.post( @router.post(
'', '/runs',
summary='行任务', summary='行任务',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:task:run')), Depends(RequestPermission('sys:task:run')),
DependsRBAC, DependsRBAC,
+9 -10
View File
@@ -5,7 +5,6 @@ from typing import Any
import celery import celery
import celery_aio_pool import celery_aio_pool
from backend.app.task.conf import task_settings
from backend.core.conf import settings from backend.core.conf import settings
__all__ = ['celery_app'] __all__ = ['celery_app']
@@ -13,14 +12,14 @@ __all__ = ['celery_app']
def get_broker_url() -> str: def get_broker_url() -> str:
"""获取消息代理 URL""" """获取消息代理 URL"""
if task_settings.CELERY_BROKER == 'redis': if settings.CELERY_BROKER == 'redis':
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
return ( return (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
@@ -28,16 +27,16 @@ def get_result_backend() -> str:
"""获取结果后端 URL""" """获取结果后端 URL"""
return ( return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BACKEND_REDIS_DATABASE}'
) )
def get_result_backend_transport_options() -> dict[str, Any]: def get_result_backend_transport_options() -> dict[str, Any]:
"""获取结果后端传输选项""" """获取结果后端传输选项"""
return { return {
'global_keyprefix': task_settings.CELERY_BACKEND_REDIS_PREFIX, 'global_keyprefix': settings.CELERY_BACKEND_REDIS_PREFIX,
'retry_policy': { 'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT, 'timeout': settings.CELERY_BACKEND_REDIS_TIMEOUT,
}, },
} }
@@ -55,7 +54,7 @@ def init_celery() -> celery.Celery:
'fba_celery', 'fba_celery',
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
beat_schedule=task_settings.CELERY_SCHEDULE, beat_schedule=settings.CELERY_SCHEDULE,
broker_url=get_broker_url(), broker_url=get_broker_url(),
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=get_result_backend(), result_backend=get_result_backend(),
@@ -65,7 +64,7 @@ def init_celery() -> celery.Celery:
) )
# 自动发现任务 # 自动发现任务
app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES) app.autodiscover_tasks(settings.CELERY_TASK_PACKAGES)
return app return app
+2 -2
View File
@@ -7,15 +7,15 @@ from typing import Any
from celery import Task from celery import Task
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from backend.app.task.conf import task_settings
from backend.common.socketio.actions import task_notification from backend.common.socketio.actions import task_notification
from backend.core.conf import settings
class TaskBase(Task): class TaskBase(Task):
"""Celery 任务基类""" """Celery 任务基类"""
autoretry_for = (SQLAlchemyError,) autoretry_for = (SQLAlchemyError,)
max_retries = task_settings.CELERY_TASK_MAX_RETRIES max_retries = settings.CELERY_TASK_MAX_RETRIES
async def before_start(self, task_id: str, args, kwargs) -> None: async def before_start(self, task_id: str, args, kwargs) -> None:
""" """
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BASE_PATH
class TaskSettings(BaseSettings):
"""Celery 任务配置"""
model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# .env 环境
ENVIRONMENT: Literal['dev', 'pro']
# .env Redis 配置
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ 配置
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
RABBITMQ_HOST: str
RABBITMQ_PORT: int
RABBITMQ_USERNAME: str
RABBITMQ_PASSWORD: str
# Celery 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# Celery 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
@model_validator(mode='before')
@classmethod
def validate_celery_broker(cls, values: Any) -> Any:
"""生产环境强制使用 RabbitMQ 作为消息代理"""
if values['ENVIRONMENT'] == 'pro':
values['CELERY_BROKER'] = 'rabbitmq'
return values
@lru_cache
def get_task_settings() -> TaskSettings:
"""获取 Celery 任务配置"""
return TaskSettings()
task_settings = get_task_settings()
+3 -3
View File
@@ -19,11 +19,11 @@ class TaskResult(SchemaBase):
"""任务执行结果""" """任务执行结果"""
result: str = Field(description='任务执行结果') result: str = Field(description='任务执行结果')
traceback: str = Field(description='错误堆栈信息') traceback: str | None = Field(None, description='错误堆栈信息')
status: str = Field(description='任务状态') status: str = Field(description='任务状态')
name: str = Field(description='任务名称') name: str | None = Field(None, description='任务名称')
args: list[Any] | None = Field(None, description='任务函数位置参数') args: list[Any] | None = Field(None, description='任务函数位置参数')
kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数') kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
worker: str = Field(description='执行任务的 worker') worker: str | None = Field(None, description='执行任务的 worker')
retries: int | None = Field(None, description='重试次数') retries: int | None = Field(None, description='重试次数')
queue: str | None = Field(None, description='任务队列') queue: str | None = Field(None, description='任务队列')
+12 -13
View File
@@ -7,21 +7,11 @@ from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app from backend.app.task.celery import celery_app
from backend.app.task.schema.task import RunParam, TaskResult from backend.app.task.schema.task import RunParam, TaskResult
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.exception.errors import NotFoundError
class TaskService: class TaskService:
@staticmethod @staticmethod
async def get_list() -> list[str]: def get(*, tid: str) -> TaskResult:
"""获取所有已注册的 Celery 任务列表"""
registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered)
if not registered_tasks:
raise errors.ForbiddenError(msg='Celery 服务未启动')
tasks = list(registered_tasks.values())[0]
return tasks
@staticmethod
def get_detail(*, tid: str) -> TaskResult:
""" """
获取指定任务的详细信息 获取指定任务的详细信息
@@ -31,7 +21,7 @@ class TaskService:
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
raise NotFoundError(msg='任务不存在') raise errors.NotFoundError(msg='任务不存在')
return TaskResult( return TaskResult(
result=result.result, result=result.result,
traceback=result.traceback, traceback=result.traceback,
@@ -44,6 +34,15 @@ class TaskService:
queue=result.queue, queue=result.queue,
) )
@staticmethod
async def get_all() -> list[str]:
"""获取所有已注册的 Celery 任务列表"""
registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered)
if not registered_tasks:
raise errors.ServerError(msg='Celery 服务未启动')
tasks = list(registered_tasks.values())[0]
return tasks
@staticmethod @staticmethod
def revoke(*, tid: str) -> None: def revoke(*, tid: str) -> None:
""" """
@@ -55,7 +54,7 @@ class TaskService:
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
raise NotFoundError(msg='任务不存在') raise errors.NotFoundError(msg='任务不存在')
result.revoke(terminate=True) result.revoke(terminate=True)
@staticmethod @staticmethod
+18 -7
View File
@@ -34,13 +34,6 @@ class RequestCallNext:
response: Response response: Response
@dataclasses.dataclass
class NewToken:
new_access_token: str
new_access_token_expire_time: datetime
session_uuid: str
@dataclasses.dataclass @dataclasses.dataclass
class AccessToken: class AccessToken:
access_token: str access_token: str
@@ -54,6 +47,15 @@ class RefreshToken:
refresh_token_expire_time: datetime refresh_token_expire_time: datetime
@dataclasses.dataclass
class NewToken:
new_access_token: str
new_access_token_expire_time: datetime
new_refresh_token: str
new_refresh_token_expire_time: datetime
session_uuid: str
@dataclasses.dataclass @dataclasses.dataclass
class TokenPayload: class TokenPayload:
id: int id: int
@@ -64,3 +66,12 @@ class TokenPayload:
@dataclasses.dataclass @dataclasses.dataclass
class UploadUrl: class UploadUrl:
url: str url: str
@dataclasses.dataclass
class SnowflakeInfo:
timestamp: int
datetime: str
cluster_id: int
node_id: int
sequence: int
+18
View File
@@ -44,6 +44,8 @@ class MenuType(IntEnum):
directory = 0 directory = 0
menu = 1 menu = 1
button = 2 button = 2
embedded = 3
link = 4
class RoleDataRuleOperatorType(IntEnum): class RoleDataRuleOperatorType(IntEnum):
@@ -119,3 +121,19 @@ class FileType(StrEnum):
image = 'image' image = 'image'
video = 'video' video = 'video'
class PluginType(StrEnum):
"""插件类型"""
zip = 'zip'
git = 'git'
class UserPermissionType(StrEnum):
"""用户权限类型"""
superuser = 'superuser'
staff = 'staff'
status = 'status'
multi_login = 'multi_login'
+10 -1
View File
@@ -85,7 +85,7 @@ class GatewayError(BaseExceptionMixin):
class AuthorizationError(BaseExceptionMixin): class AuthorizationError(BaseExceptionMixin):
"""授权异常""" """授权异常"""
code = StandardResponseCode.HTTP_401 code = StandardResponseCode.HTTP_403
def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None):
super().__init__(msg=msg, data=data, background=background) super().__init__(msg=msg, data=data, background=background)
@@ -98,3 +98,12 @@ class TokenError(HTTPError):
def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None): def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None):
super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'}) super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'})
class ConflictError(BaseExceptionMixin):
"""资源冲突异常"""
code = StandardResponseCode.HTTP_409
def __init__(self, *, msg: str = 'Conflict', data: Any = None, background: BackgroundTask | None = None):
super().__init__(msg=msg, data=data, background=background)
@@ -76,7 +76,7 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation
} }
request.state.__request_validation_exception__ = content # 用于在中间件中获取异常信息 request.state.__request_validation_exception__ = content # 用于在中间件中获取异常信息
content.update(trace_id=get_request_trace_id(request)) content.update(trace_id=get_request_trace_id(request))
return MsgSpecJSONResponse(status_code=422, content=content) return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content)
def register_exception(app: FastAPI): def register_exception(app: FastAPI):
+28 -2
View File
@@ -3,17 +3,43 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from sqlalchemy import DateTime from sqlalchemy import BigInteger, DateTime
from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
from backend.utils.snowflake import snowflake
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
# 通用 Mapped 类型主键, 需手动添加,参考以下使用方式 # 通用 Mapped 类型主键, 需手动添加,参考以下使用方式
# MappedBase -> id: Mapped[id_key] # MappedBase -> id: Mapped[id_key]
# DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False) # DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False)
id_key = Annotated[ id_key = Annotated[
int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键 ID') int,
mapped_column(
BigInteger,
primary_key=True,
unique=True,
index=True,
autoincrement=True,
sort_order=-999,
comment='主键 ID',
),
]
# 雪花算法 Mapped 类型主键,使用方法与 id_key 相同
# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html
snowflake_id_key = Annotated[
int,
mapped_column(
BigInteger,
primary_key=True,
unique=True,
index=True,
default=snowflake.generate,
sort_order=-999,
comment='雪花算法主键 ID',
),
] ]
-13
View File
@@ -23,21 +23,8 @@ class CustomResponseCode(CustomCodeBase):
"""自定义响应状态码""" """自定义响应状态码"""
HTTP_200 = (200, '请求成功') HTTP_200 = (200, '请求成功')
HTTP_201 = (201, '新建请求成功')
HTTP_202 = (202, '请求已接受,但处理尚未完成')
HTTP_204 = (204, '请求成功,但没有返回内容')
HTTP_400 = (400, '请求错误') HTTP_400 = (400, '请求错误')
HTTP_401 = (401, '未经授权')
HTTP_403 = (403, '禁止访问')
HTTP_404 = (404, '请求的资源不存在')
HTTP_410 = (410, '请求的资源已永久删除')
HTTP_422 = (422, '请求参数非法')
HTTP_425 = (425, '无法执行请求,由于服务器无法满足要求')
HTTP_429 = (429, '请求过多,服务器限制')
HTTP_500 = (500, '服务器内部错误') HTTP_500 = (500, '服务器内部错误')
HTTP_502 = (502, '网关错误')
HTTP_503 = (503, '服务器暂时无法处理请求')
HTTP_504 = (504, '网关超时')
class CustomErrorCode(CustomCodeBase): class CustomErrorCode(CustomCodeBase):
+77 -41
View File
@@ -6,8 +6,9 @@ from datetime import timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import Depends, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials
from fastapi.security.utils import get_authorization_scheme_param from fastapi.security.utils import get_authorization_scheme_param
from jose import ExpiredSignatureError, JWTError, jwt from jose import ExpiredSignatureError, JWTError, jwt
from pwdlib import PasswordHash from pwdlib import PasswordHash
@@ -18,15 +19,33 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload
from backend.common.exception.errors import AuthorizationError, TokenError from backend.common.exception import errors
from backend.common.exception.errors import TokenError
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.serializers import select_as_dict from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
class CustomHTTPBearer(HTTPBearer):
"""
自定义 HTTPBearer 认证类
Issues: https://github.com/fastapi/fastapi/issues/10177
"""
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
try:
return await super().__call__(request)
except HTTPException as e:
if e.status_code == 403:
raise TokenError()
raise e
# JWT authorizes dependency injection # JWT authorizes dependency injection
DependsJwtAuth = Depends(HTTPBearer()) DependsJwtAuth = Depends(CustomHTTPBearer())
password_hash = PasswordHash((BcryptHasher(),)) password_hash = PasswordHash((BcryptHasher(),))
@@ -60,11 +79,7 @@ def jwt_encode(payload: dict[str, Any]) -> str:
:param payload: 载荷 :param payload: 载荷
:return: :return:
""" """
return jwt.encode( return jwt.encode(payload, settings.TOKEN_SECRET_KEY, settings.TOKEN_ALGORITHM)
payload,
settings.TOKEN_SECRET_KEY,
settings.TOKEN_ALGORITHM,
)
def jwt_decode(token: str) -> TokenPayload: def jwt_decode(token: str) -> TokenPayload:
@@ -75,20 +90,27 @@ def jwt_decode(token: str) -> TokenPayload:
:return: :return:
""" """
try: try:
payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM]) payload = jwt.decode(
session_uuid = payload.get('session_uuid') or 'debug' token,
settings.TOKEN_SECRET_KEY,
algorithms=[settings.TOKEN_ALGORITHM],
options={'verify_exp': True},
)
session_uuid = payload.get('session_uuid')
user_id = payload.get('sub') user_id = payload.get('sub')
expire_time = payload.get('exp') expire = payload.get('exp')
if not user_id: if not session_uuid or not user_id or not expire:
raise TokenError(msg='Token 无效') raise errors.TokenError(msg='Token 无效')
except ExpiredSignatureError: except ExpiredSignatureError:
raise TokenError(msg='Token 已过期') raise errors.TokenError(msg='Token 已过期')
except (JWTError, Exception): except (JWTError, Exception):
raise TokenError(msg='Token 无效') raise errors.TokenError(msg='Token 无效')
return TokenPayload(id=int(user_id), session_uuid=session_uuid, expire_time=expire_time) return TokenPayload(
id=int(user_id), session_uuid=session_uuid, expire_time=timezone.from_datetime(timezone.to_utc(expire))
)
async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> AccessToken: async def create_access_token(user_id: int, multi_login: bool, **kwargs) -> AccessToken:
""" """
生成加密 token 生成加密 token
@@ -101,8 +123,8 @@ async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> Acce
session_uuid = str(uuid4()) session_uuid = str(uuid4())
access_token = jwt_encode({ access_token = jwt_encode({
'session_uuid': session_uuid, 'session_uuid': session_uuid,
'exp': expire, 'exp': timezone.to_utc(expire).timestamp(),
'sub': user_id, 'sub': str(user_id),
}) })
if not multi_login: if not multi_login:
@@ -117,7 +139,7 @@ async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> Acce
# Token 附加信息单独存储 # Token 附加信息单独存储
if kwargs: if kwargs:
await redis_client.setex( await redis_client.setex(
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}', f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}',
settings.TOKEN_EXPIRE_SECONDS, settings.TOKEN_EXPIRE_SECONDS,
json.dumps(kwargs, ensure_ascii=False), json.dumps(kwargs, ensure_ascii=False),
) )
@@ -125,51 +147,65 @@ async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> Acce
return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid) return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid)
async def create_refresh_token(user_id: str, multi_login: bool) -> RefreshToken: async def create_refresh_token(session_uuid: str, user_id: int, multi_login: bool) -> RefreshToken:
""" """
生成加密刷新 token仅用于创建新的 token 生成加密刷新 token仅用于创建新的 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID :param user_id: 用户 ID
:param multi_login: 是否允许多端登录 :param multi_login: 是否允许多端登录
:return: :return:
""" """
expire = timezone.now() + timedelta(seconds=settings.TOKEN_REFRESH_EXPIRE_SECONDS) expire = timezone.now() + timedelta(seconds=settings.TOKEN_REFRESH_EXPIRE_SECONDS)
refresh_token = jwt_encode({'exp': expire, 'sub': user_id}) refresh_token = jwt_encode({
'session_uuid': session_uuid,
'exp': timezone.to_utc(expire).timestamp(),
'sub': str(user_id),
})
if not multi_login: if not multi_login:
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}' await redis_client.delete_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
await redis_client.delete_prefix(key_prefix)
await redis_client.setex( await redis_client.setex(
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}',
settings.TOKEN_REFRESH_EXPIRE_SECONDS, settings.TOKEN_REFRESH_EXPIRE_SECONDS,
refresh_token, refresh_token,
) )
return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire) return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire)
async def create_new_token(user_id: str, refresh_token: str, multi_login: bool, **kwargs) -> NewToken: async def create_new_token(
refresh_token: str, session_uuid: str, user_id: int, multi_login: bool, **kwargs
) -> NewToken:
""" """
生成新的 token 生成新的 token
:param user_id: 用户 ID
:param refresh_token: 刷新 token :param refresh_token: 刷新 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录 :param multi_login: 是否允许多端登录
:param kwargs: token 附加信息 :param kwargs: token 附加信息
:return: :return:
""" """
redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}') redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
if not redis_refresh_token or redis_refresh_token != refresh_token: if not redis_refresh_token or redis_refresh_token != refresh_token:
raise TokenError(msg='Refresh Token 已过期,请重新登录') raise errors.TokenError(msg='Refresh Token 已过期,请重新登录')
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
new_access_token = await create_access_token(user_id, multi_login, **kwargs) new_access_token = await create_access_token(user_id, multi_login, **kwargs)
new_refresh_token = await create_refresh_token(new_access_token.session_uuid, user_id, multi_login)
return NewToken( return NewToken(
new_access_token=new_access_token.access_token, new_access_token=new_access_token.access_token,
new_access_token_expire_time=new_access_token.access_token_expire_time, new_access_token_expire_time=new_access_token.access_token_expire_time,
new_refresh_token=new_refresh_token.refresh_token,
new_refresh_token_expire_time=new_refresh_token.refresh_token_expire_time,
session_uuid=new_access_token.session_uuid, session_uuid=new_access_token.session_uuid,
) )
async def revoke_token(user_id: str, session_uuid: str) -> None: async def revoke_token(user_id: int, session_uuid: str) -> None:
""" """
撤销 token 撤销 token
@@ -177,8 +213,8 @@ async def revoke_token(user_id: str, session_uuid: str) -> None:
:param session_uuid: 会话 ID :param session_uuid: 会话 ID
:return: :return:
""" """
token_key = f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}' await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(token_key) await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
def get_token(request: Request) -> str: def get_token(request: Request) -> str:
@@ -191,7 +227,7 @@ def get_token(request: Request) -> str:
authorization = request.headers.get('Authorization') authorization = request.headers.get('Authorization')
scheme, token = get_authorization_scheme_param(authorization) scheme, token = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != 'bearer': if not authorization or scheme.lower() != 'bearer':
raise TokenError(msg='Token 无效') raise errors.TokenError(msg='Token 无效')
return token return token
@@ -207,18 +243,18 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
user = await user_dao.get_with_relation(db, user_id=pk) user = await user_dao.get_with_relation(db, user_id=pk)
if not user: if not user:
raise TokenError(msg='Token 无效') raise errors.TokenError(msg='Token 无效')
if not user.status: if not user.status:
raise AuthorizationError(msg='用户已被锁定,请联系系统管理员') raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员')
if user.dept_id: if user.dept_id:
if not user.dept.status: if not user.dept.status:
raise AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员') raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员')
if user.dept.del_flag: if user.dept.del_flag:
raise AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员') raise errors.AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员')
if user.roles: if user.roles:
role_status = [role.status for role in user.roles] role_status = [role.status for role in user.roles]
if all(status == 0 for status in role_status): if all(status == 0 for status in role_status):
raise AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员') raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员')
return user return user
@@ -231,7 +267,7 @@ def superuser_verify(request: Request) -> bool:
""" """
superuser = request.user.is_superuser superuser = request.user.is_superuser
if not superuser or not request.user.is_staff: if not superuser or not request.user.is_staff:
raise AuthorizationError raise errors.AuthorizationError()
return superuser return superuser
@@ -246,10 +282,10 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
user_id = token_payload.id user_id = token_payload.id
redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
if not redis_token: if not redis_token:
raise TokenError(msg='Token 已过期') raise errors.TokenError(msg='Token 已过期')
if token != redis_token: if token != redis_token:
raise TokenError(msg='Token 已失效') raise errors.TokenError(msg='Token 已失效')
cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
if not cache_user: if not cache_user:
+22 -23
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import TYPE_CHECKING
from fastapi import Request from fastapi import Request
from sqlalchemy import ColumnElement, and_, or_ from sqlalchemy import ColumnElement, and_, or_
@@ -9,13 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.exception.errors import ServerError
from backend.core.conf import settings from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model from backend.utils.import_parse import dynamic_import_data_model
if TYPE_CHECKING:
from backend.app.admin.model import DataRule
class RequestPermission: class RequestPermission:
""" """
@@ -44,7 +39,7 @@ class RequestPermission:
""" """
if settings.RBAC_ROLE_MENU_MODE: if settings.RBAC_ROLE_MENU_MODE:
if not isinstance(self.value, str): if not isinstance(self.value, str):
raise ServerError raise errors.ServerError
# 附加权限标识到请求状态 # 附加权限标识到请求状态
request.state.permission = self.value request.state.permission = self.value
@@ -60,35 +55,39 @@ async def filter_data_permission(db: AsyncSession, request: Request) -> ColumnEl
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
# 获取用户角色和数据范围 # 是否过滤数据权限
data_scopes = [] if request.user.is_superuser:
return or_(1 == 1)
for role in request.user.roles:
if not role.is_filter_scopes:
return or_(1 == 1)
# 获取数据范围
data_scope_ids = set()
for role in request.user.roles: for role in request.user.roles:
for scope in role.scopes: for scope in role.scopes:
if scope.status: if scope.status:
data_scopes.append(scope) data_scope_ids.add(scope.id)
# 超级管理员和无规则用户不做过滤 # 无规则用户不做过滤
if request.user.is_superuser or not data_scopes: if not list(data_scope_ids):
return or_(1 == 1) return or_(1 == 1)
# 获取数据范围规则 # 获取数据范围规则
data_rule_list: list[DataRule] = [] unique_data_rules = {}
for data_scope in data_scopes: for data_scope_id in list(data_scope_ids):
data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope.id) data_scope_with_relation = await data_scope_dao.get_with_relation(db, data_scope_id)
data_rule_list.extend(data_scope_with_relation.rules) for rule in data_scope_with_relation.rules:
unique_data_rules[rule.id] = rule
# 去重 # 转换为列表
seen_data_rule_ids = set() data_rule_list = list(unique_data_rules.values())
new_data_rule_list = []
for rule in data_rule_list:
if rule.id not in seen_data_rule_ids:
seen_data_rule_ids.add(rule.id)
new_data_rule_list.append(rule)
where_and_list = [] where_and_list = []
where_or_list = [] where_or_list = []
for data_rule in new_data_rule_list: for data_rule in data_rule_list:
# 验证规则模型 # 验证规则模型
rule_model = data_rule.model rule_model = data_rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS: if rule_model not in settings.DATA_PERMISSION_MODELS:
+13 -9
View File
@@ -4,7 +4,6 @@ from fastapi import Depends, Request
from backend.common.enums import MethodType, StatusType from backend.common.enums import MethodType, StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.exception.errors import AuthorizationError, TokenError
from backend.common.log import log from backend.common.log import log
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.core.conf import settings from backend.core.conf import settings
@@ -27,7 +26,7 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
# JWT 授权状态强制校验 # JWT 授权状态强制校验
if not request.auth.scopes: if not request.auth.scopes:
raise TokenError raise errors.TokenError
# 超级管理员免校验 # 超级管理员免校验
if request.user.is_superuser: if request.user.is_superuser:
@@ -36,17 +35,17 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
# 检测用户角色 # 检测用户角色
user_roles = request.user.roles user_roles = request.user.roles
if not user_roles or all(status == 0 for status in user_roles): if not user_roles or all(status == 0 for status in user_roles):
raise AuthorizationError(msg='用户未分配角色,请联系系统管理员') raise errors.AuthorizationError(msg='用户未分配角色,请联系系统管理员')
# 检测用户所属角色菜单 # 检测用户所属角色菜单
if not any(len(role.menus) > 0 for role in user_roles): if not any(len(role.menus) > 0 for role in user_roles):
raise AuthorizationError(msg='用户未分配菜单,请联系系统管理员') raise errors.AuthorizationError(msg='用户未分配菜单,请联系系统管理员')
# 检测后台管理操作权限 # 检测后台管理操作权限
method = request.method method = request.method
if method != MethodType.GET or method != MethodType.OPTIONS: if method != MethodType.GET or method != MethodType.OPTIONS:
if not request.user.is_staff: if not request.user.is_staff:
raise AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员') raise errors.AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员')
# RBAC 鉴权 # RBAC 鉴权
if settings.RBAC_ROLE_MENU_MODE: if settings.RBAC_ROLE_MENU_MODE:
@@ -60,17 +59,22 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE: if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE:
return return
# 已分配菜单权限校验 # 菜单去重
allow_perms = [] unique_menus = {}
for role in user_roles: for role in user_roles:
for menu in role.menus: for menu in role.menus:
unique_menus[menu.id] = menu
# 已分配菜单权限校验
allow_perms = []
for menu in list(unique_menus.values()):
if menu.perms and menu.status == StatusType.enable: if menu.perms and menu.status == StatusType.enable:
allow_perms.extend(menu.perms.split(',')) allow_perms.extend(menu.perms.split(','))
if path_auth_perm not in allow_perms: if path_auth_perm not in allow_perms:
raise AuthorizationError raise errors.AuthorizationError
else: else:
try: try:
casbin_rbac = import_module_cached('backend.plugin.casbin.utils.rbac') casbin_rbac = import_module_cached('backend.plugin.casbin_rbac.rbac')
casbin_verify = getattr(casbin_rbac, 'casbin_verify') casbin_verify = getattr(casbin_rbac, 'casbin_verify')
except (ImportError, AttributeError) as e: except (ImportError, AttributeError) as e:
log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}') log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}')
+4 -5
View File
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import socketio import socketio
from backend.app.task.conf import task_settings
from backend.common.log import log from backend.common.log import log
from backend.common.security.jwt import jwt_authentication from backend.common.security.jwt import jwt_authentication
from backend.core.conf import settings from backend.core.conf import settings
@@ -13,13 +12,13 @@ sio = socketio.AsyncServer(
# 集成 Celery 实现消息订阅 # 集成 Celery 实现消息订阅
client_manager=socketio.AsyncRedisManager( client_manager=socketio.AsyncRedisManager(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:' f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}' f'{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
) )
if task_settings.CELERY_BROKER == 'redis' if settings.CELERY_BROKER == 'redis'
else socketio.AsyncAioPikaManager( else socketio.AsyncAioPikaManager(
( (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@' f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}' f'{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}'
) )
), ),
async_mode='asgi', async_mode='asgi',
+65 -3
View File
@@ -3,6 +3,7 @@
from functools import lru_cache from functools import lru_cache
from typing import Any, Literal from typing import Any, Literal
from celery.schedules import crontab
from pydantic import model_validator from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -44,7 +45,7 @@ class Settings(BaseSettings):
# FastAPI # FastAPI
FASTAPI_API_V1_PATH: str = '/api/v1' FASTAPI_API_V1_PATH: str = '/api/v1'
FASTAPI_TITLE: str = 'FastAPI' FASTAPI_TITLE: str = 'FastAPI'
FASTAPI_VERSION: str = '0.0.1' FASTAPI_VERSION: str = '1.5.0'
FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture' FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture'
FASTAPI_DOCS_URL: str = '/docs' FASTAPI_DOCS_URL: str = '/docs'
FASTAPI_REDOC_URL: str = '/redoc' FASTAPI_REDOC_URL: str = '/redoc'
@@ -70,6 +71,7 @@ class Settings(BaseSettings):
TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:refresh_token' TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:refresh_token'
TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单 TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单
f'{FASTAPI_API_V1_PATH}/auth/login', f'{FASTAPI_API_V1_PATH}/auth/login',
f'{FASTAPI_API_V1_PATH}/auth/logout',
] ]
# JWT # JWT
@@ -180,17 +182,77 @@ class Settings(BaseSettings):
'confirm_password', 'confirm_password',
] ]
# 插件配置 # Plugin 配置
PLUGIN_PIP_CHINA: bool = True PLUGIN_PIP_CHINA: bool = True
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/' PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
# App Admin
# .env OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str
# OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
# App Task
# .env Redis
CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int
# .env RabbitMQ
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
CELERY_RABBITMQ_HOST: str
CELERY_RABBITMQ_PORT: int
CELERY_RABBITMQ_USERNAME: str
CELERY_RABBITMQ_PASSWORD: str
# 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5
CELERY_TASK_PACKAGES: list[str] = [
'app.task.celery_task',
'app.task.celery_task.db_log',
]
CELERY_TASK_MAX_RETRIES: int = 5
# 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': {
'task': 'task_demo_async',
'schedule': 10,
},
'exec-every-sunday': {
'task': 'delete_db_opera_log',
'schedule': crontab('0', '0', day_of_week='6'),
},
'exec-every-15-of-month': {
'task': 'delete_db_login_log',
'schedule': crontab('0', '0', day_of_month='15'),
},
}
# Plugin Code Generator
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def check_env(cls, values: Any) -> Any: def check_env(cls, values: Any) -> Any:
"""生产环境下禁用 OpenAPI 文档和静态文件服务""" """检查环境变量"""
if values.get('ENVIRONMENT') == 'pro': if values.get('ENVIRONMENT') == 'pro':
# FastAPI
values['FASTAPI_OPENAPI_URL'] = None values['FASTAPI_OPENAPI_URL'] = None
values['FASTAPI_STATIC_FILES'] = False values['FASTAPI_STATIC_FILES'] = False
# Task
values['CELERY_BROKER'] = 'rabbitmq'
return values return values
-2
View File
@@ -40,8 +40,6 @@ async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
""" """
# 创建数据库表 # 创建数据库表
await create_table() await create_table()
# 连接 redis
await redis_client.open()
# 初始化 limiter # 初始化 limiter
await FastAPILimiter.init( await FastAPILimiter.init(
redis=redis_client, redis=redis_client,
+1 -1
View File
@@ -9,6 +9,6 @@ from backend.plugin.code_generator.api.v1.gen import router as gen_router
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成']) v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
v1.include_router(gen_router)
v1.include_router(business_router, prefix='/businesses') v1.include_router(business_router, prefix='/businesses')
v1.include_router(model_router, prefix='/models') v1.include_router(model_router, prefix='/models')
v1.include_router(gen_router, prefix='/codes')
@@ -20,12 +20,6 @@ from backend.plugin.code_generator.service.column_service import gen_model_servi
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有代码生成业务', dependencies=[DependsJwtAuth])
async def get_all_businesses() -> ResponseSchemaModel[list[GetGenBusinessDetail]]:
data = await gen_business_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取代码生成业务详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取代码生成业务详情', dependencies=[DependsJwtAuth])
async def get_business( async def get_business(
pk: Annotated[int, Path(description='业务 ID')], pk: Annotated[int, Path(description='业务 ID')],
@@ -34,11 +28,17 @@ async def get_business(
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有代码生成业务', dependencies=[DependsJwtAuth])
async def get_all_businesses() -> ResponseSchemaModel[list[GetGenBusinessDetail]]:
data = await gen_business_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}/models', summary='获取代码生成业务所有模型', dependencies=[DependsJwtAuth]) @router.get('/{pk}/models', summary='获取代码生成业务所有模型', dependencies=[DependsJwtAuth])
async def get_business_all_models( async def get_business_all_models(
pk: Annotated[int, Path(description='业务 ID')], pk: Annotated[int, Path(description='业务 ID')],
) -> ResponseSchemaModel[list[GetGenModelDetail]]: ) -> ResponseSchemaModel[list[GetGenModelDetail]]:
data = await gen_model_service.get_by_business(business_id=pk) data = await gen_model_service.get_models(business_id=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -47,7 +47,7 @@ async def get_business_all_models(
summary='创建代码生成业务', summary='创建代码生成业务',
deprecated=True, deprecated=True,
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:business:add')), Depends(RequestPermission('codegen:business:add')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -60,7 +60,7 @@ async def create_business(obj: CreateGenBusinessParam) -> ResponseModel:
'/{pk}', '/{pk}',
summary='更新代码生成业务', summary='更新代码生成业务',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:business:edit')), Depends(RequestPermission('codegen:business:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -77,7 +77,7 @@ async def update_business(
'/{pk}', '/{pk}',
summary='删除代码生成业务', summary='删除代码生成业务',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:business:del')), Depends(RequestPermission('codegen:business:del')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -30,7 +30,7 @@ async def get_model(pk: Annotated[int, Path(description='模型 ID')]) -> Respon
'', '',
summary='创建代码生成模型', summary='创建代码生成模型',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:model:add')), Depends(RequestPermission('codegen:model:add')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -43,7 +43,7 @@ async def create_model(obj: CreateGenModelParam) -> ResponseModel:
'/{pk}', '/{pk}',
summary='更新代码生成模型', summary='更新代码生成模型',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:model:edit')), Depends(RequestPermission('codegen:model:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -58,7 +58,7 @@ async def update_model(pk: Annotated[int, Path(description='模型 ID')], obj: U
'/{pk}', '/{pk}',
summary='删除代码生成模型', summary='删除代码生成模型',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:model:del')), Depends(RequestPermission('codegen:model:del')),
DependsRBAC, DependsRBAC,
], ],
) )
+10 -10
View File
@@ -9,7 +9,7 @@ from backend.common.response.response_schema import ResponseModel, ResponseSchem
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.plugin.code_generator.conf import generator_settings from backend.core.conf import settings
from backend.plugin.code_generator.schema.gen import ImportParam from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service from backend.plugin.code_generator.service.gen_service import gen_service
@@ -25,10 +25,10 @@ async def get_all_tables(
@router.post( @router.post(
'/import', '/imports',
summary='导入代码生成业务和模型列', summary='导入代码生成业务和模型列',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:import')), Depends(RequestPermission('codegen:table:import')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -37,24 +37,24 @@ async def import_table(obj: ImportParam) -> ResponseModel:
return response_base.success() return response_base.success()
@router.get('/preview/{pk}', summary='生成代码预览', dependencies=[DependsJwtAuth]) @router.get('/{pk}/previews', summary='代码生成预览', dependencies=[DependsJwtAuth])
async def preview_code(pk: Annotated[int, Path(description='业务 ID')]) -> ResponseSchemaModel[dict[str, bytes]]: async def preview_code(pk: Annotated[int, Path(description='业务 ID')]) -> ResponseSchemaModel[dict[str, bytes]]:
data = await gen_service.preview(pk=pk) data = await gen_service.preview(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/generate/{pk}/path', summary='获取代码生成路径', dependencies=[DependsJwtAuth]) @router.get('/{pk}/paths', summary='获取代码生成路径', dependencies=[DependsJwtAuth])
async def generate_path(pk: Annotated[int, Path(description='业务 ID')]) -> ResponseSchemaModel[list[str]]: async def get_generate_paths(pk: Annotated[int, Path(description='业务 ID')]) -> ResponseSchemaModel[list[str]]:
data = await gen_service.get_generate_path(pk=pk) data = await gen_service.get_generate_path(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.post( @router.post(
'/generate/{pk}', '/{pk}/generation',
summary='代码生成', summary='代码生成',
description='文件磁盘写入,请谨慎操作', description='文件磁盘写入,请谨慎操作',
dependencies=[ dependencies=[
Depends(RequestPermission('gen:code:generate')), Depends(RequestPermission('codegen:local:write')),
DependsRBAC, DependsRBAC,
], ],
) )
@@ -63,11 +63,11 @@ async def generate_code(pk: Annotated[int, Path(description='业务 ID')]) -> Re
return response_base.success() return response_base.success()
@router.get('/download/{pk}', summary='下载代码', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='下载代码', dependencies=[DependsJwtAuth])
async def download_code(pk: Annotated[int, Path(description='业务 ID')]): async def download_code(pk: Annotated[int, Path(description='业务 ID')]):
bio = await gen_service.download(pk=pk) bio = await gen_service.download(pk=pk)
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
headers={'Content-Disposition': f'attachment; filename={generator_settings.DOWNLOAD_ZIP_FILENAME}.zip'}, headers={'Content-Disposition': f'attachment; filename={settings.CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME}.zip'},
) )
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
class GeneratorSettings(BaseSettings):
"""代码生成配置"""
# 代码下载
DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
@lru_cache
def get_generator_settings() -> GeneratorSettings:
"""获取代码生成配置"""
return GeneratorSettings()
generator_settings = get_generator_settings()
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import TYPE_CHECKING, Union from typing import TYPE_CHECKING, Union
from sqlalchemy import ForeignKey, String from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -33,6 +33,6 @@ class GenColumn(DataClassBase):
# 代码生成业务模型一对多 # 代码生成业务模型一对多
gen_business_id: Mapped[int] = mapped_column( gen_business_id: Mapped[int] = mapped_column(
ForeignKey('gen_business.id', ondelete='CASCADE'), default=0, comment='代码生成业务ID' BigInteger, ForeignKey('gen_business.id', ondelete='CASCADE'), default=0, comment='代码生成业务ID'
) )
gen_business: Mapped[Union['GenBusiness', None]] = relationship(init=False, back_populates='gen_column') gen_business: Mapped[Union['GenBusiness', None]] = relationship(init=False, back_populates='gen_column')
@@ -1,2 +1,8 @@
[plugin]
summary = '代码生成'
version = '0.0.1'
description = '生成通用业务代码'
author = 'wu-clan'
[app] [app]
router = ['v1'] router = ['v1']
@@ -43,7 +43,7 @@ class GenBusinessService:
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
business = await gen_business_dao.get_by_name(db, obj.table_name) business = await gen_business_dao.get_by_name(db, obj.table_name)
if business: if business:
raise errors.ForbiddenError(msg='代码生成业务已存在') raise errors.ConflictError(msg='代码生成业务已存在')
await gen_business_dao.create(db, obj) await gen_business_dao.create(db, obj)
@staticmethod @staticmethod
@@ -36,7 +36,7 @@ class GenModelService:
return types return types
@staticmethod @staticmethod
async def get_by_business(*, business_id: int) -> Sequence[GenColumn]: async def get_models(*, business_id: int) -> Sequence[GenColumn]:
""" """
获取指定业务的所有模型 获取指定业务的所有模型
@@ -76,7 +76,7 @@ class GenModelService:
if obj.name != model.name: if obj.name != model.name:
gen_models = await gen_model_dao.get_all_by_business(db, obj.gen_business_id) gen_models = await gen_model_dao.get_all_by_business(db, obj.gen_business_id)
if obj.name in [gen_model.name for gen_model in gen_models]: if obj.name in [gen_model.name for gen_model in gen_models]:
raise errors.ForbiddenError(msg='模型列名已存在') raise errors.ConflictError(msg='模型列名已存在')
pd_type = sql_type_to_pydantic(obj.type) pd_type = sql_type_to_pydantic(obj.type)
return await gen_model_dao.update(db, pk, obj, pd_type=pd_type) return await gen_model_dao.update(db, pk, obj, pd_type=pd_type)
@@ -55,7 +55,7 @@ class GenService:
business_info = await gen_business_dao.get_by_name(db, obj.table_name) business_info = await gen_business_dao.get_by_name(db, obj.table_name)
if business_info: if business_info:
raise errors.ForbiddenError(msg='已存在相同数据库表业务') raise errors.ConflictError(msg='已存在相同数据库表业务')
table_name = table_info[0] table_name = table_info[0]
new_business = GenBusiness( new_business = GenBusiness(
@@ -99,7 +99,7 @@ class GenService:
:param business: 业务对象 :param business: 业务对象
:return: :return:
""" """
gen_models = await gen_model_service.get_by_business(business_id=business.id) gen_models = await gen_model_service.get_models(business_id=business.id)
if not gen_models: if not gen_models:
raise errors.NotFoundError(msg='代码生成模型表为空') raise errors.NotFoundError(msg='代码生成模型表为空')
@@ -175,6 +175,7 @@ class GenService:
code_folder.mkdir(parents=True, exist_ok=True) code_folder.mkdir(parents=True, exist_ok=True)
init_filepath = code_folder.joinpath('__init__.py') init_filepath = code_folder.joinpath('__init__.py')
if not os.path.exists(init_filepath):
async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f: async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content) await f.write(gen_template.init_content)
@@ -4,7 +4,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Get{{ schema_name }}Detail, Update{{ schema_name }}Param from backend.app.{{ app_name }}.schema.{{ table_name }} import (
Create{{ schema_name }}Param,
Delete{{ schema_name }}Param,
Get{{ schema_name }}Detail,
Update{{ schema_name }}Param,
)
from backend.app.{{ app_name }}.service.{{ table_name }}_service import {{ table_name }}_service from backend.app.{{ app_name }}.service.{{ table_name }}_service import {{ table_name }}_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -30,7 +35,7 @@ async def get_{{ table_name }}(pk: Annotated[int, Path(description='{{ doc_comme
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_{{ table_name }}s(db: CurrentSession) -> ResponseSchemaModel[PageData[Get{{ schema_name }}Detail]]: async def get_{{ table_name }}s_paged(db: CurrentSession) -> ResponseSchemaModel[PageData[Get{{ schema_name }}Detail]]:
{{ table_name }}_select = await {{ table_name }}_service.get_select() {{ table_name }}_select = await {{ table_name }}_service.get_select()
page_data = await paging_data(db, {{ table_name }}_select) page_data = await paging_data(db, {{ table_name }}_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -72,8 +77,8 @@ async def update_{{ table_name }}(pk: Annotated[int, Path(description='{{ doc_co
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_{{ table_name }}(pk: Annotated[list[int], Query(description='{{ doc_comment }} ID 列表')]) -> ResponseModel: async def delete_{{ table_name }}s(obj: Delete{{ schema_name }}Param) -> ResponseModel:
count = await {{ table_name }}_service.delete(pk=pk) count = await {{ table_name }}_service.delete(obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -55,15 +55,15 @@ class CRUD{{ class_name }}(CRUDPlus[{{ schema_name }}]):
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除{{ doc_comment }} 批量删除{{ doc_comment }}
:param db: 数据库会话 :param db: 数据库会话
:param pk: {{ doc_comment }} ID :param pks: {{ doc_comment }} ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
{{ instance_name }}_dao: CRUD{{ class_name }} = CRUD{{ class_name }}({{ class_name }}) {{ instance_name }}_dao: CRUD{{ class_name }} = CRUD{{ class_name }}({{ class_name }})
@@ -23,6 +23,10 @@ class Update{{ schema_name }}Param({{ schema_name }}SchemaBase):
"""更新{{ doc_comment }}参数""" """更新{{ doc_comment }}参数"""
class Delete{{ schema_name }}Param({{ schema_name }}SchemaBase):
"""删除{{ doc_comment }}参数"""
class Get{{ schema_name }}Detail({{ schema_name }}SchemaBase): class Get{{ schema_name }}Detail({{ schema_name }}SchemaBase):
"""{{ doc_comment }}详情""" """{{ doc_comment }}详情"""
@@ -6,7 +6,7 @@ from sqlalchemy import Select
from backend.app.{{ app_name }}.crud.crud_{{ table_name }} import {{ table_name }}_dao from backend.app.{{ app_name }}.crud.crud_{{ table_name }} import {{ table_name }}_dao
from backend.app.{{ app_name }}.model import {{ class_name }} from backend.app.{{ app_name }}.model import {{ class_name }}
from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Update{{ schema_name }}Param from backend.app.{{ app_name }}.schema.{{ table_name }} import Create{{ schema_name }}Param, Delete{{ schema_name }}Param, Update{{ schema_name }}Param
from backend.common.exception import errors from backend.common.exception import errors
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -63,15 +63,15 @@ class {{ class_name }}Service:
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, obj: Delete{{ schema_name }}Param) -> int:
""" """
删除{{ doc_comment }} 删除{{ doc_comment }}
:param pk: {{ doc_comment }} ID 列表 :param obj: {{ doc_comment }} ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await {{ table_name }}_dao.delete(db, pk) count = await {{ table_name }}_dao.delete(db, obj.pks)
return count return count
+3
View File
@@ -0,0 +1,3 @@
## 参数配置
内置插件,可直接使用
+6 -64
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Body, Depends, Path, Query
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -13,7 +13,6 @@ from backend.database.db import CurrentSession
from backend.plugin.config.schema.config import ( from backend.plugin.config.schema.config import (
CreateConfigParam, CreateConfigParam,
GetConfigDetail, GetConfigDetail,
SaveBuiltInConfigParam,
UpdateConfigParam, UpdateConfigParam,
) )
from backend.plugin.config.service.config_service import config_service from backend.plugin.config.service.config_service import config_service
@@ -21,66 +20,9 @@ from backend.plugin.config.service.config_service import config_service
router = APIRouter() router = APIRouter()
@router.get('/website', summary='获取网站参数配置', dependencies=[DependsJwtAuth])
async def get_website_config() -> ResponseSchemaModel[list[GetConfigDetail]]:
config = await config_service.get_built_in_config('website')
return response_base.success(data=config)
@router.post(
'/website',
summary='保存网站参数配置',
dependencies=[
Depends(RequestPermission('sys:config:website:add')),
DependsRBAC,
],
)
async def save_website_config(objs: list[SaveBuiltInConfigParam]) -> ResponseModel:
await config_service.save_built_in_config(objs, 'website')
return response_base.success()
@router.get('/protocol', summary='获取用户协议', dependencies=[DependsJwtAuth])
async def get_protocol_config() -> ResponseSchemaModel[list[GetConfigDetail]]:
config = await config_service.get_built_in_config('protocol')
return response_base.success(data=config)
@router.post(
'/protocol',
summary='保存用户协议',
dependencies=[
Depends(RequestPermission('sys:config:protocol:add')),
DependsRBAC,
],
)
async def save_protocol_config(objs: list[SaveBuiltInConfigParam]) -> ResponseModel:
await config_service.save_built_in_config(objs, 'protocol')
return response_base.success()
@router.get('/policy', summary='获取用户政策', dependencies=[DependsJwtAuth])
async def get_policy_config() -> ResponseSchemaModel[list[GetConfigDetail]]:
config = await config_service.get_built_in_config('policy')
return response_base.success(data=config)
@router.post(
'/policy',
summary='保存用户政策',
dependencies=[
Depends(RequestPermission('sys:config:policy:add')),
DependsRBAC,
],
)
async def save_policy_config(objs: list[SaveBuiltInConfigParam]) -> ResponseModel:
await config_service.save_built_in_config(objs, 'policy')
return response_base.success()
@router.get('/{pk}', summary='获取参数配置详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取参数配置详情', dependencies=[DependsJwtAuth])
async def get_config(pk: Annotated[int, Path(description='参数配置 ID')]) -> ResponseSchemaModel[GetConfigDetail]: async def get_config(pk: Annotated[int, Path(description='参数配置 ID')]) -> ResponseSchemaModel[GetConfigDetail]:
config = await config_service.get(pk) config = await config_service.get(pk=pk)
return response_base.success(data=config) return response_base.success(data=config)
@@ -92,10 +34,10 @@ async def get_config(pk: Annotated[int, Path(description='参数配置 ID')]) ->
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_configs( async def get_configs_paged(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query(description='参数配置名称')] = None, name: Annotated[str | None, Query(description='参数配置名称')] = None,
type: Annotated[str | None, Query()] = None, type: Annotated[str | None, Query(description='参数配置类型')] = None,
) -> ResponseSchemaModel[PageData[GetConfigDetail]]: ) -> ResponseSchemaModel[PageData[GetConfigDetail]]:
config_select = await config_service.get_select(name=name, type=type) config_select = await config_service.get_select(name=name, type=type)
page_data = await paging_data(db, config_select) page_data = await paging_data(db, config_select)
@@ -138,8 +80,8 @@ async def update_config(pk: Annotated[int, Path(description='参数配置 ID')],
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_config(pk: Annotated[list[int], Query(description='参数配置 ID 列表')]) -> ResponseModel: async def delete_configs(pks: Annotated[list[int], Body(description='参数配置 ID 列表')]) -> ResponseModel:
count = await config_service.delete(pk=pk) count = await config_service.delete(pks=pks)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from functools import lru_cache
from pydantic_settings import BaseSettings
class ConfigSettings(BaseSettings):
"""参数配置"""
# 参数
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
@lru_cache
def get_config_settings() -> ConfigSettings:
"""获取参数配置"""
return ConfigSettings()
config_settings = get_config_settings()
+10 -33
View File
@@ -1,12 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.config.conf import config_settings
from backend.plugin.config.model import Config from backend.plugin.config.model import Config
from backend.plugin.config.schema.config import CreateConfigParam, UpdateConfigParam from backend.plugin.config.schema.config import CreateConfigParam, UpdateConfigParam
@@ -22,28 +20,7 @@ class CRUDConfig(CRUDPlus[Config]):
:param pk: 参数配置 ID :param pk: 参数配置 ID
:return: :return:
""" """
return await self.select_model_by_column(db, id=pk, type__not_in=config_settings.CONFIG_BUILT_IN_TYPES) return await self.select_model_by_column(db, id=pk)
async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config]:
"""
通过类型获取参数配置
:param db: 数据库会话
:param type: 参数配置类型
:return:
"""
return await self.select_models(db, type=type)
async def get_by_key_and_type(self, db: AsyncSession, key: str, type: str) -> Config | None:
"""
通过键名和类型获取参数配置
:param db: 数据库会话
:param key: 参数配置键名
:param type: 参数配置类型
:return:
"""
return await self.select_model_by_column(db, key=key, type=type)
async def get_by_key(self, db: AsyncSession, key: str) -> Config | None: async def get_by_key(self, db: AsyncSession, key: str) -> Config | None:
""" """
@@ -63,11 +40,13 @@ class CRUDConfig(CRUDPlus[Config]):
:param type: 参数配置类型 :param type: 参数配置类型
:return: :return:
""" """
filters = {'type__not_in': config_settings.CONFIG_BUILT_IN_TYPES} filters = {}
if name is not None: if name is not None:
filters.update(name__like=f'%{name}%') filters['name__like'] = f'%{name}%'
if type is not None: if type is not None:
filters.update(type__like=f'%{type}%') filters['type__like'] = f'%{type}%'
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateConfigParam) -> None: async def create(self, db: AsyncSession, obj: CreateConfigParam) -> None:
@@ -91,17 +70,15 @@ class CRUDConfig(CRUDPlus[Config]):
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除参数配置 批量删除参数配置
:param db: 数据库会话 :param db: 数据库会话
:param pk: 参数配置 ID 列表 :param pks: 参数配置 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column( return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
db, allow_multiple=True, id__in=pk, type__not_in=config_settings.CONFIG_BUILT_IN_TYPES
)
config_dao: CRUDConfig = CRUDConfig(Config) config_dao: CRUDConfig = CRUDConfig(Config)
+6
View File
@@ -1,3 +1,9 @@
[plugin]
summary = '参数配置'
version = '0.0.1'
description = '通常用于前端工程数据展示'
author = 'wu-clan'
[app] [app]
include = 'admin' include = 'admin'
-8
View File
@@ -7,14 +7,6 @@ from pydantic import ConfigDict, Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class SaveBuiltInConfigParam(SchemaBase):
"""保存内置参数配置参数"""
name: str = Field(description='参数配置名称')
key: str = Field(description='参数配置键名')
value: str = Field(description='参数配置值')
class ConfigSchemaBase(SchemaBase): class ConfigSchemaBase(SchemaBase):
"""参数配置基础模型""" """参数配置基础模型"""
@@ -1,17 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from backend.common.exception import errors from backend.common.exception import errors
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.plugin.config.conf import config_settings
from backend.plugin.config.crud.crud_config import config_dao from backend.plugin.config.crud.crud_config import config_dao
from backend.plugin.config.model import Config from backend.plugin.config.model import Config
from backend.plugin.config.schema.config import ( from backend.plugin.config.schema.config import (
CreateConfigParam, CreateConfigParam,
SaveBuiltInConfigParam,
UpdateConfigParam, UpdateConfigParam,
) )
@@ -20,37 +17,7 @@ class ConfigService:
"""参数配置服务类""" """参数配置服务类"""
@staticmethod @staticmethod
async def get_built_in_config(type: str) -> Sequence[Config]: async def get(*, pk: int) -> Config:
"""
获取内置参数配置
:param type: 参数配置类型
:return:
"""
async with async_db_session() as db:
return await config_dao.get_by_type(db, type)
@staticmethod
async def save_built_in_config(objs: list[SaveBuiltInConfigParam], type: str) -> None:
"""
保存内置参数配置
:param objs: 参数配置参数列表
:param type: 参数配置类型
:return:
"""
async with async_db_session.begin() as db:
for obj in objs:
config = await config_dao.get_by_key_and_type(db, obj.key, type)
if config is None:
if await config_dao.get_by_key(db, obj.key):
raise errors.ForbiddenError(msg=f'参数配置 {obj.key} 已存在')
await config_dao.create_model(db, obj, flush=True, type=type)
else:
await config_dao.update_model(db, config.id, obj, type=type)
@staticmethod
async def get(pk: int) -> Config:
""" """
获取参数配置详情 获取参数配置详情
@@ -83,11 +50,9 @@ class ConfigService:
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if obj.type in config_settings.CONFIG_BUILT_IN_TYPES:
raise errors.ForbiddenError(msg='非法类型参数')
config = await config_dao.get_by_key(db, obj.key) config = await config_dao.get_by_key(db, obj.key)
if config: if config:
raise errors.ForbiddenError(msg=f'参数配置 {obj.key} 已存在') raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
await config_dao.create(db, obj) await config_dao.create(db, obj)
@staticmethod @staticmethod
@@ -106,20 +71,20 @@ class ConfigService:
if config.key != obj.key: if config.key != obj.key:
config = await config_dao.get_by_key(db, obj.key) config = await config_dao.get_by_key(db, obj.key)
if config: if config:
raise errors.ForbiddenError(msg=f'参数配置 {obj.key} 已存在') raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
count = await config_dao.update(db, pk, obj) count = await config_dao.update(db, pk, obj)
return count return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pks: list[int]) -> int:
""" """
删除参数配置 批量删除参数配置
:param pk: 参数配置 ID 列表 :param pks: 参数配置 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await config_dao.delete(db, pk) count = await config_dao.delete(db, pks)
return count return count

Some files were not shown because too many files have changed in this diff Show More