Compare commits

...
44 Commits
Author SHA1 Message Date
Wu Clan 338e50e255 Update the routing style of the task app (#588)
* Update the routing style of the task app

* Fix prefix
2025-04-23 19:33:36 +08:00
Wu Clan 65500d7a53 Bump fastapi pagination from 0.12.34 to 0.13.0 (#587) 2025-04-23 13:04:09 +08:00
Wu Clan 0833cd5443 Optimize the userinfo cache cleaning logic (#586) 2025-04-23 12:40:27 +08:00
Wu Clan e8c844ead7 Fix cache cleanup when updating role menu (#585) 2025-04-21 17:58:01 +08:00
Wu Clan 1d5f2dc5f0 Fix the condition to query menu by title (#584) 2025-04-21 17:57:12 +08:00
Wu Clanandwu-clan 9a36154564 Update the changelog for v1.1.1 (#583)
This commit was created by changelog-from-release in 'Release changelog' CI workflow

Co-authored-by: wu-clan <wu-clan@users.noreply.github.com>
2025-04-18 23:19:01 +08:00
Wu Clan 5f8ae74ea5 Fix list query in the dict plugin (#582) 2025-04-18 23:16:03 +08:00
Wu Clan da8e7c1b01 Fix the plugin system route injection (#581) 2025-04-18 23:09:25 +08:00
Wu Clan f513b98f15 Update the changelog for v1.1.0 (#580) 2025-04-17 17:54:52 +08:00
Wu Clan 80cef6dde7 Update code generator file and table naming (#579)
* Update code generator file and table naming

* Update column table comment
2025-04-17 17:47:20 +08:00
Huyuwei 2d504f281e Fix avatar url type of update avatar (#575) 2025-04-17 15:02:19 +08:00
Wu Clan fc34c98f0a Update the code generator to plugin (#578)
* Update the code generator to plugin

* Fix get all tables return type
2025-04-17 11:06:43 +08:00
Wu Clan 1cc020efdd Update unique columns in dict models (#577) 2025-04-17 11:06:29 +08:00
Wu Clan bb33e99e33 Update casbin RBAC verify to dynamic import (#576) 2025-04-17 11:06:15 +08:00
Wu Clan 6c70b34ae6 Optimize the file structure of code generator (#574) 2025-04-15 19:54:18 +08:00
Wu Clan 960d9f6215 Update the default value for some functions (#573)
* Update the default value for some functions

* Fix lint
2025-04-14 14:22:05 +08:00
Wu Clan 085f08ec3b Update the changelog for v1.0.5 (#572) 2025-04-09 11:55:49 +08:00
Wu Clan 2a8722f56e Fix the log rule in gitignore (#571) 2025-04-09 11:53:55 +08:00
Wu Clan 4372b9bbc2 Bump fastapi oauth2 from 0.0.1a2 to 0.0.1 (#570) 2025-04-09 11:49:04 +08:00
Wu Clan db81319dc4 Fix the failure hook of celery task (#569) 2025-04-08 12:09:29 +08:00
Wu Clan 644f7a0413 Fix the login password verification (#568)
* Fix the login password verification

* Update the check criteria
2025-04-07 11:04:39 +08:00
Wu Clan b93ff19ebe Update the number of pagination le (#565) 2025-04-03 19:32:26 +08:00
Wu Clan 4c007cb47d Update custom validation error messages (#566) 2025-04-03 19:32:11 +08:00
Wu Clan c9e9a45288 Update the vben5 tree data structure (#564) 2025-04-02 23:18:55 +08:00
Wu Clan d44a5491f5 Update menu and add vben5 compatibility (#563)
* Update menu and add vben5 compatibility

* Update sidebar api description
2025-04-02 22:20:19 +08:00
Wu Clan 289cf5b974 Update dict data and type to plugin (#562) 2025-04-02 14:04:06 +08:00
Wu Clan e207622a70 Update the system config to plugin (#561) 2025-04-02 13:43:24 +08:00
Wu Clan 38d9d9fda5 Add python 3.13 to GitHub ci (#560) 2025-03-28 14:22:45 +08:00
Wu Clan 996895e775 Bump dependencies and pre-commits (#559) 2025-03-28 14:22:20 +08:00
Wu Clan 0675e53f2a Update the changelog for v1.0.4 (#558) 2025-03-28 10:15:11 +08:00
Wu Clan e492cec7d7 Optimize codes and comments with cursor (#550) 2025-03-28 10:08:32 +08:00
PoetryL 92fe1e7554 Fix the query for the sub department (#557) 2025-03-27 23:30:12 +08:00
Wu Clan 7b080600f6 Update volumes of redis in docker compose (#556) 2025-03-26 18:58:06 +08:00
Wu Clan 71f9667bc0 Fix the celery env in docker compose (#555) 2025-03-26 18:34:41 +08:00
Thank CAT 2bf548f8b9 Fix filters for opera log query list (#554) 2025-03-25 09:19:28 +08:00
Wu Clan 15fd4545aa Optimize schemas with model relationships (#552) 2025-03-20 18:46:13 +08:00
Wu Clan 7f6c4ad6da Fix return schema of the config api (#551) 2025-03-20 16:27:44 +08:00
Wu Clan 2be12b4ea4 Fix async install plugin dependencies for windows (#549) 2025-03-17 23:39:10 +08:00
Wu Clan baebdd9870 Optimize dependencies to reduce package size (#548) 2025-03-17 18:41:58 +08:00
Wu Clan d1648bb709 Update git and docker ignore files (#547)
* Update git and docker ignore files

* add __pycache__ dir

* add .python-version
2025-03-17 13:20:21 +08:00
Wu Clan a4a394a18d Optimize the dynamic import of data models (#546) 2025-03-16 15:13:09 +08:00
Wu Clan 951fdc902a Optimize the return of relationship interfaces (#545)
* Optimize the return of relationship interfaces

* Fix gen business detail schema
2025-03-13 18:30:05 +08:00
Wu Clan 18ffa06d70 Updated the backend contribution guide (#544) 2025-03-12 18:15:10 +08:00
Wu Clan d28f152d78 Update the changelog for v1.0.3 (#543) 2025-03-12 14:23:38 +08:00
216 changed files with 5611 additions and 4024 deletions
+9 -6
View File
@@ -1,6 +1,9 @@
**/__pycache__ __pycache__/
**/venv .git/
**/.venv .github/
**/.python-version .idea/
**/.git .vscode/
**/.github venv/
.venv/
.ruff_cache/
.pytest_cache/
+2 -2
View File
@@ -12,13 +12,13 @@ jobs:
name: lint ${{ matrix.python-version }} name: lint ${{ matrix.python-version }}
strategy: strategy:
matrix: matrix:
python-version: [ '3.10', '3.11', '3.12' ] python-version: [ '3.10', '3.11', '3.12', '3.13' ]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v3 uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }}
+7
View File
@@ -1,2 +1,9 @@
.idea/ .idea/
.vscode/ .vscode/
.cursor/
.DS_Store
venv/
.venv/
.python-version
.ruff_cache/
.pytest_cache/
+2 -2
View File
@@ -8,7 +8,7 @@ repos:
- id: check-toml - id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.9.5 rev: v0.11.2
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.5.29 rev: 0.6.10
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+108
View File
@@ -1,3 +1,106 @@
<a id="v1.1.1"></a>
# [v1.1.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.1) - 2025-04-18
## What's Changed
* Update changelog for v1.1.0 by [@wu-clan](https://github.com/wu-clan) in [#580](https://github.com/fastapi-practices/fastapi_best_architecture/pull/580)
* Fix the plugin system route injection by [@wu-clan](https://github.com/wu-clan) in [#581](https://github.com/fastapi-practices/fastapi_best_architecture/pull/581)
* Fix list query in the dict plugin by [@wu-clan](https://github.com/wu-clan) in [#582](https://github.com/fastapi-practices/fastapi_best_architecture/pull/582)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
[Changes][v1.1.1]
<a id="v1.1.0"></a>
# [v1.1.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.1.0) - 2025-04-17
## What's Changed
* Update the changelog for v1.0.5 by [@wu-clan](https://github.com/wu-clan) in [#572](https://github.com/fastapi-practices/fastapi_best_architecture/pull/572)
* Update the default value for some functions by [@wu-clan](https://github.com/wu-clan) in [#573](https://github.com/fastapi-practices/fastapi_best_architecture/pull/573)
* Optimize the file structure of code generator by [@wu-clan](https://github.com/wu-clan) in [#574](https://github.com/fastapi-practices/fastapi_best_architecture/pull/574)
* Update casbin RBAC verify to dynamic import by [@wu-clan](https://github.com/wu-clan) in [#576](https://github.com/fastapi-practices/fastapi_best_architecture/pull/576)
* Update unique columns in dict models by [@wu-clan](https://github.com/wu-clan) in [#577](https://github.com/fastapi-practices/fastapi_best_architecture/pull/577)
* Update the code generator to plugin by [@wu-clan](https://github.com/wu-clan) in [#578](https://github.com/fastapi-practices/fastapi_best_architecture/pull/578)
* Fix avatar url type of update avatar by [@huyuwei1996](https://github.com/huyuwei1996) in [#575](https://github.com/fastapi-practices/fastapi_best_architecture/pull/575)
* Update code generator file and table naming by [@wu-clan](https://github.com/wu-clan) in [#579](https://github.com/fastapi-practices/fastapi_best_architecture/pull/579)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
[Changes][v1.1.0]
<a id="v1.0.5"></a>
# [v1.0.5](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.5) - 2025-04-09
## What's Changed
* Update the changelog for v1.0.4 by [@wu-clan](https://github.com/wu-clan) in [#558](https://github.com/fastapi-practices/fastapi_best_architecture/pull/558)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#559](https://github.com/fastapi-practices/fastapi_best_architecture/pull/559)
* Add python 3.13 to GitHub ci by [@wu-clan](https://github.com/wu-clan) in [#560](https://github.com/fastapi-practices/fastapi_best_architecture/pull/560)
* Update the system config to plugin by [@wu-clan](https://github.com/wu-clan) in [#561](https://github.com/fastapi-practices/fastapi_best_architecture/pull/561)
* Update dict data and type to plugin by [@wu-clan](https://github.com/wu-clan) in [#562](https://github.com/fastapi-practices/fastapi_best_architecture/pull/562)
* Update menu and add vben5 compatibility by [@wu-clan](https://github.com/wu-clan) in [#563](https://github.com/fastapi-practices/fastapi_best_architecture/pull/563)
* Update the vben5 tree data structure by [@wu-clan](https://github.com/wu-clan) in [#564](https://github.com/fastapi-practices/fastapi_best_architecture/pull/564)
* Update custom validation error messages by [@wu-clan](https://github.com/wu-clan) in [#566](https://github.com/fastapi-practices/fastapi_best_architecture/pull/566)
* Update the number of pagination le by [@wu-clan](https://github.com/wu-clan) in [#565](https://github.com/fastapi-practices/fastapi_best_architecture/pull/565)
* Fix the login password verification by [@wu-clan](https://github.com/wu-clan) in [#568](https://github.com/fastapi-practices/fastapi_best_architecture/pull/568)
* Fix the failure hook of celery task by [@wu-clan](https://github.com/wu-clan) in [#569](https://github.com/fastapi-practices/fastapi_best_architecture/pull/569)
* Bump fastapi oauth2 from 0.0.1a2 to 0.0.1 by [@wu-clan](https://github.com/wu-clan) in [#570](https://github.com/fastapi-practices/fastapi_best_architecture/pull/570)
* Fix the log rule in gitignore by [@wu-clan](https://github.com/wu-clan) in [#571](https://github.com/fastapi-practices/fastapi_best_architecture/pull/571)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
[Changes][v1.0.5]
<a id="v1.0.4"></a>
# [v1.0.4](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.4) - 2025-03-28
## What's Changed
* Update the changelog for v1.0.3 by [@wu-clan](https://github.com/wu-clan) in [#543](https://github.com/fastapi-practices/fastapi_best_architecture/pull/543)
* Updated the backend contribution guide by [@wu-clan](https://github.com/wu-clan) in [#544](https://github.com/fastapi-practices/fastapi_best_architecture/pull/544)
* Optimize the return of relationship interfaces by [@wu-clan](https://github.com/wu-clan) in [#545](https://github.com/fastapi-practices/fastapi_best_architecture/pull/545)
* Optimize the dynamic import of data models by [@wu-clan](https://github.com/wu-clan) in [#546](https://github.com/fastapi-practices/fastapi_best_architecture/pull/546)
* Update git and docker ignore files by [@wu-clan](https://github.com/wu-clan) in [#547](https://github.com/fastapi-practices/fastapi_best_architecture/pull/547)
* Optimize dependencies to reduce package size by [@wu-clan](https://github.com/wu-clan) in [#548](https://github.com/fastapi-practices/fastapi_best_architecture/pull/548)
* Fix async install plugin dependencies for windows by [@wu-clan](https://github.com/wu-clan) in [#549](https://github.com/fastapi-practices/fastapi_best_architecture/pull/549)
* Fix return schema of the config api by [@wu-clan](https://github.com/wu-clan) in [#551](https://github.com/fastapi-practices/fastapi_best_architecture/pull/551)
* Optimize schemas with model relationships by [@wu-clan](https://github.com/wu-clan) in [#552](https://github.com/fastapi-practices/fastapi_best_architecture/pull/552)
* Fix filters for opera log query list by [@ThankCat](https://github.com/ThankCat) in [#554](https://github.com/fastapi-practices/fastapi_best_architecture/pull/554)
* Fix the celery env in docker compose by [@wu-clan](https://github.com/wu-clan) in [#555](https://github.com/fastapi-practices/fastapi_best_architecture/pull/555)
* Update volumes of redis in docker compose by [@wu-clan](https://github.com/wu-clan) in [#556](https://github.com/fastapi-practices/fastapi_best_architecture/pull/556)
* Fix the query for the sub department by [@PoetryL](https://github.com/PoetryL) in [#557](https://github.com/fastapi-practices/fastapi_best_architecture/pull/557)
* Optimize codes and comments with cursor by [@wu-clan](https://github.com/wu-clan) in [#550](https://github.com/fastapi-practices/fastapi_best_architecture/pull/550)
## New Contributors
* [@ThankCat](https://github.com/ThankCat) made their first contribution in [#554](https://github.com/fastapi-practices/fastapi_best_architecture/pull/554)
* [@PoetryL](https://github.com/PoetryL) made their first contribution in [#557](https://github.com/fastapi-practices/fastapi_best_architecture/pull/557)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
[Changes][v1.0.4]
<a id="v1.0.3"></a>
# [v1.0.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.3) - 2025-03-11
## What's Changed
* Update the changelog for v1.0.2 by [@wu-clan](https://github.com/wu-clan) in [#536](https://github.com/fastapi-practices/fastapi_best_architecture/pull/536)
* Update docker scripts in backend README by [@wu-clan](https://github.com/wu-clan) in [#537](https://github.com/fastapi-practices/fastapi_best_architecture/pull/537)
* Refactor toml and dependencies file dir by [@wu-clan](https://github.com/wu-clan) in [#538](https://github.com/fastapi-practices/fastapi_best_architecture/pull/538)
* Fix typos in Dockerfile comments by [@huyuwei1996](https://github.com/huyuwei1996) in [#539](https://github.com/fastapi-practices/fastapi_best_architecture/pull/539)
* Fix Dockerfile mounts for dependency installation by [@huyuwei1996](https://github.com/huyuwei1996) in [#540](https://github.com/fastapi-practices/fastapi_best_architecture/pull/540)
* Add Aliyun mirror to PyPI index in pyproject.toml by [@huyuwei1996](https://github.com/huyuwei1996) in [#541](https://github.com/fastapi-practices/fastapi_best_architecture/pull/541)
* Update docker scripts and nginx conf by [@wu-clan](https://github.com/wu-clan) in [#542](https://github.com/fastapi-practices/fastapi_best_architecture/pull/542)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
[Changes][v1.0.3]
<a id="v1.0.2"></a> <a id="v1.0.2"></a>
# [v1.0.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.2) - 2025-03-01 # [v1.0.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.0.2) - 2025-03-01
@@ -357,6 +460,11 @@
[Changes][v1.0.0] [Changes][v1.0.0]
[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.0.5]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
[v1.0.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
[v1.0.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
[v1.0.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2 [v1.0.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2
[v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1 [v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0 [v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
+33 -33
View File
@@ -6,9 +6,7 @@
English | [简体中文](./README.zh-CN.md) English | [简体中文](./README.zh-CN.md)
A backend and frontend separation solution based on the FastAPI framework, following Enterprise-level backend architecture solution
the [pseudo 3-tier architecture](#pseudo-3-tier-architecture) design, supporting **Python 3.10** and above
versions
**🔥Continuously updated and maintained🔥** **🔥Continuously updated and maintained🔥**
@@ -49,37 +47,39 @@ pattern, use templates to transform it to your heart's content!
## Features ## Features
- [x] Design with FastAPI PEP 593 Annotated Parameters - [x] Global FastAPI PEP 593 Annotated parameter style
- [x] Global asynchronous design with async/await + asgiref - [x] Comprehensive async/await + asgiref asynchronous design
- [x] Follows Restful API specification - [x] Adheres to RESTful API specifications
- [x] Global SQLAlchemy 2.0 syntax - [x] Uses SQLAlchemy 2.0 with new syntax
- [x] Pydantic v1 and v2 (different branches) - [x] Uses Pydantic v2 version
- [x] Casbin RBAC access control model - [x] Implements role-menu RBAC access control
- [x] Role menu RBAC access control model - [x] Integrates Casbin RBAC access control
- [x] Celery asynchronous tasks - [x] Supports Celery asynchronous tasks
- [x] JWT middleware whitelist authentication - [x] Custom-developed JWT authentication middleware
- [x] Global customizable time zone time - [x] Supports global custom time zones
- [x] Docker / Docker-compose deployment - [x] Supports Docker / Docker-compose deployment
- [x] Pytest Unit Testing - [x] Integrates Pytest unit testing
## Built-in features ## Built-in Functions
- [x] User management: System User Role Management, Permission Allocation - [x] User Management: Assign roles and permissions
- [x] Department management: Configure system organization (company, department, team...) - [x] Department Management: Configure organizational structure (company, department, team, etc.)
- [x] Menu management: Configure system menu, user menu, button permission tags - [x] Menu Management: Set up menus and button-level permissions
- [x] Role management: role menu permission allocation, role route permission allocation - [x] Role Management: Configure roles, assign menus and permissions
- [x] Dictionary management: Maintain commonly used fixed data or parameters within the system - [x] Dictionary Management: Maintain common parameters and configurations
- [x] Token management: System user online status detection, supports kicking users offline - [x] Parameter Management: Dynamically configure commonly used system parameters
- [x] Login authentication: backend-based graphical captcha background authentication login - [x] Notification Announcements: Publish and maintain system notification and announcement information
- [x] Multipoint login: One-click modification of multipoint login through user information - [x] Token Management: Detect online status, support forced logout
- [x] OAuth 2.0: Built-in self-developed OAuth 2.0 login integration - [x] Multi-device Login: Support one-click switching between multi-device login modes
- [x] Code generation: automatic backend code generation, supports preview, writing, and download - [x] OAuth 2.0: Built-in custom-developed OAuth 2.0 authorization login
- [x] Scheduled task: Automated task, asynchronous task, supports function calls - [x] Plugin System: Hot-swappable plugin design to reduce coupling
- [x] Plugin system: Say goodbye to high coupling integration through hot-pluggable plugin mode - [x] Scheduled Tasks: Support scheduled, asynchronous tasks, and function calls
- [x] Operation log: Record and query of system normal and abnormal operations - [x] Code Generation: Automatically generate code with preview, write, and download support
- [x] Login log: Record and query of normal and abnormal user login - [x] Operation Logs: Record and query normal and abnormal operations
- [x] Service monitoring: Server hardware device information and status - [x] Login Logs: Record and query normal and abnormal logins
- [x] API documentation: Automatically generate online interactive API documentation - [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 ## Development and deployment
@@ -103,7 +103,7 @@ the [official documentation](https://fastapi-practices.github.io/fastapi_best_ar
## Interactivity ## Interactivity
[TG / Discord](https://wu-clan.github.io/homepage/) [Discord](https://wu-clan.github.io/homepage/)
## Sponsor us ## Sponsor us
+31 -29
View File
@@ -6,7 +6,7 @@
简体中文 | [English](./README.md) 简体中文 | [English](./README.md)
基于 FastAPI 框架的前后端分离中后台解决方案,遵循[伪三层架构](#伪三层架构)设计, 支持 **python3.10** 及以上版本 企业级后端架构解决方案
**🔥持续更新维护中🔥** **🔥持续更新维护中🔥**
@@ -43,38 +43,40 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud | | 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model | | 模型 | model / entity | model |
## 特 ## 特
- [x] 全局 FastAPI PEP 593 Annotated 参数风格 - [x] 全局 FastAPI PEP 593 Annotated 参数风格
- [x] async/await + asgiref 的全局异步设计 - [x] 全面 async/await + asgiref 异步设计
- [x] 遵循 Restful API 规范 - [x] 遵循 RESTful API 规范
- [x] 全局 SQLAlchemy 2.0 语法 - [x] 使用 SQLAlchemy 2.0 全新语法
- [x] Pydantic v1 和 v2 (不同分支) - [x] 使用 Pydantic v2 版本
- [x] Casbin RBAC 访问控制模型 - [x] 实现角色菜单 RBAC 访问控制
- [x] 角色菜单 RBAC 访问控制模型 - [x] 集成 Casbin RBAC 访问控制
- [x] Celery 异步任务 - [x] 支持 Celery 异步任务
- [x] JWT 中间件白名单认证 - [x] 自研 JWT 认证中间件
- [x] 全局自定义时区时 - [x] 支持全局自定义时间时区
- [x] Docker / Docker-compose 部署 - [x] 支持 Docker / Docker-compose 部署
- [x] Pytest 单元测试 - [x] 集成 Pytest 单元测试
## 内置功能 ## 内置功能
- [x] 用户管理:系统用户角色管理,权限分配 - [x] 用户管理:分配角色和权限
- [x] 部门管理:配置系统组织构(公司、部门、小组... - [x] 部门管理:配置组织构(公司、部门、小组
- [x] 菜单管理:配置系统菜单,用户菜单,按钮权限标识 - [x] 菜单管理:设置菜单及按钮权限
- [x] 角色管理:角色菜单权限分配,角色路由权限分配 - [x] 角色管理:配置角色、分配菜单权限
- [x] 字典管理:维护系统内部常用固定数据或参数 - [x] 字典管理:维护常用参数和配置
- [x] 令牌管理:系统用户在线状态检测,支持踢人下线 - [x] 参数管理:系统常用参数动态配置
- [x] 登录认证:基于后端的图形验证码后台认证登录 - [x] 通知公告:发布和维护系统通知公告信息
- [x] 多点登录:通过用户信息一键修改多点登录支持 - [x] 令牌管理:检测在线状态,支持强制下线
- [x] OAuth20:内置自研 OAuth 2.0 登录集成 - [x] 多端登录:支持一键切换多端登录模式
- [x] 代码生成:后端代码自动生成,支持预览,写入及下载 - [x] OAuth 2.0:内置自研 OAuth 2.0 授权登录
- [x] 定时任务:自动化任务,异步任务,支持函数调用 - [x] 插件系统:热插拔插件设计,降低耦合
- [x] 插件系统:通过热插拔插件模式告别高耦合集成 - [x] 定时任务:支持定时,异步任务及函数调用
- [x] 操作日志:系统正常和异常操作的日志记录与查询 - [x] 代码生成:自动生成代码,支持预览、写入和下载
- [x] 登录日志:用户正常和异常登录的日志记录与查询 - [x] 操作日志:记录和查询正常和异常操作
- [x] 服务监控:服务器硬件设备信息与状态 - [x] 登录日志:记录和查询正常和异常登录
- [x] 缓存监控:查询系统缓存信息和命令统计
- [x] 服务监控:查看服务器硬件信息和状态
- [x] 接口文档:自动生成在线交互式 API 文档 - [x] 接口文档:自动生成在线交互式 API 文档
## 开发部署 ## 开发部署
@@ -98,7 +100,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
## 互动 ## 互动
[TG / Discord](https://wu-clan.github.io/homepage/) [Discord](https://wu-clan.github.io/homepage/)
## 赞助我们 ## 赞助我们
-4
View File
@@ -1,4 +0,0 @@
__pycache__/
venv/
.venv/
.python-version
+1 -7
View File
@@ -1,12 +1,6 @@
__pycache__/ __pycache__/
.env .env
venv/
.venv/
.mypy_cache/
log/
alembic/versions/ alembic/versions/
static/media/ static/media/
.ruff_cache/ *.log
.pytest_cache/
.python-version
celerybeat-schedule.* celerybeat-schedule.*
+19 -12
View File
@@ -1,4 +1,4 @@
# FBA Project - Backend # FastAPI Best Architecture - Backend
## Docker ## Docker
@@ -21,41 +21,48 @@
1. Prerequisites 1. Prerequisites
You'll need the following prerequisites: - Python >= 3.10
- Any python version between Python >= 3.10
- Git - Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/) - [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account - Fork this repository to your GitHub account
2. Installation and setup 2. Installation and setup
Clone your fork and cd into the repo directory Clone your forked repository:
```shell ```shell
git clone https://github.com/<your username>/fastapi_best_architecture.git git clone https://github.com/<your account>/fastapi_best_architecture.git
```
cd fastapi_best_architecture/backend Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv sync --frozen uv sync --frozen
``` ```
3. Checkout a new branch and make your changes 3. Checkout
Checkout a new branch and make your changes
```shell ```shell
# Checkout a new branch and make your changes
git checkout -b your-new-feature-branch git checkout -b your-new-feature-branch
``` ```
4. Run linting 4. Format and Lint
Auto-formatting and lint via `pre-commit`
```shell ```shell
# Run automated code formatting and linting
pre-commit run --all-files pre-commit run --all-files
``` ```
5. Commit and push your changes 5. Commit and push
Commit your changes, push your branch to GitHub, and create a pull request. Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts ## Scripts
+1 -1
View File
@@ -20,7 +20,7 @@ from backend.plugin.tools import get_plugin_models
# import your new model here # import your new model here
from backend.app.admin.model import * # noqa: F401 from backend.app.admin.model import * # noqa: F401
from backend.app.generator.model import * # noqa: F401 from backend.plugin.code_generator.model import * # noqa: F401
# import plugin model # import plugin model
for cls in get_plugin_models(): for cls in get_plugin_models():
+1 -1
View File
@@ -36,7 +36,7 @@ async def user_login(
@router.post('/token/new', summary='创建新 token') @router.post('/token/new', summary='创建新 token')
async def create_new_token(request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]: async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.new_token(request=request) data = await auth_service.new_token(request=request)
return response_base.success(data=data) return response_base.success(data=data)
+6 -6
View File
@@ -18,7 +18,7 @@ router = APIRouter()
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取登录日志', summary='分页获取登录日志',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -26,9 +26,9 @@ router = APIRouter()
) )
async def get_pagination_login_logs( async def get_pagination_login_logs(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query()] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]: ) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
log_select = await login_log_service.get_select(username=username, status=status, ip=ip) log_select = await login_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select) page_data = await paging_data(db, log_select)
@@ -37,13 +37,13 @@ async def get_pagination_login_logs(
@router.delete( @router.delete(
'', '',
summary='批量删除登录日志', summary='批量删除登录日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:login:del')), Depends(RequestPermission('log:login:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_login_log(pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_login_log(pk: Annotated[list[int], Query(description='登录日志 ID 列表')]) -> ResponseModel:
count = await login_log_service.delete(pk=pk) count = await login_log_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+6 -6
View File
@@ -18,7 +18,7 @@ router = APIRouter()
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取操作日志', summary='分页获取操作日志',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -26,9 +26,9 @@ router = APIRouter()
) )
async def get_pagination_opera_logs( async def get_pagination_opera_logs(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query()] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]: ) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
log_select = await opera_log_service.get_select(username=username, status=status, ip=ip) log_select = await opera_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select) page_data = await paging_data(db, log_select)
@@ -37,13 +37,13 @@ async def get_pagination_opera_logs(
@router.delete( @router.delete(
'', '',
summary='批量删除操作日志', summary='批量删除操作日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:opera:del')), Depends(RequestPermission('log:opera:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_opera_log(pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日志 ID 列表')]) -> ResponseModel:
count = await opera_log_service.delete(pk=pk) count = await opera_log_service.delete(pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+4 -1
View File
@@ -19,5 +19,8 @@ router = APIRouter()
], ],
) )
async def get_redis_info() -> ResponseModel: async def get_redis_info() -> ResponseModel:
data = {'info': await redis_info.get_info(), 'stats': await redis_info.get_stats()} data = {
'info': await redis_info.get_info(),
'stats': await redis_info.get_stats(),
}
return response_base.success(data=data) return response_base.success(data=data)
+3 -3
View File
@@ -13,12 +13,12 @@ from backend.common.response.response_schema import ResponseSchemaModel, respons
router = APIRouter() router = APIRouter()
_github_client = GitHubOAuth20(admin_settings.OAUTH2_GITHUB_CLIENT_ID, admin_settings.OAUTH2_GITHUB_CLIENT_SECRET) _github_client = GitHubOAuth20(admin_settings.OAUTH2_GITHUB_CLIENT_ID, admin_settings.OAUTH2_GITHUB_CLIENT_SECRET)
_github_oauth2 = FastAPIOAuth20(_github_client, admin_settings.OAUTH2_GITHUB_REDIRECT_URI) _github_oauth2 = FastAPIOAuth20(_github_client, redirect_route_name='github_login')
@router.get('', summary='获取 Github 授权链接') @router.get('', summary='获取 Github 授权链接')
async def github_auth2() -> ResponseSchemaModel[str]: async def github_oauth2(request: Request) -> ResponseSchemaModel[str]:
auth_url = await _github_client.get_authorization_url(redirect_uri=admin_settings.OAUTH2_GITHUB_REDIRECT_URI) auth_url = await _github_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
return response_base.success(data=auth_url) return response_base.success(data=auth_url)
+4 -4
View File
@@ -16,12 +16,12 @@ _linux_do_client = LinuxDoOAuth20(
admin_settings.OAUTH2_LINUX_DO_CLIENT_ID, admin_settings.OAUTH2_LINUX_DO_CLIENT_ID,
admin_settings.OAUTH2_LINUX_DO_CLIENT_SECRET, admin_settings.OAUTH2_LINUX_DO_CLIENT_SECRET,
) )
_linux_do_oauth2 = FastAPIOAuth20(_linux_do_client, admin_settings.OAUTH2_LINUX_DO_REDIRECT_URI) _linux_do_oauth2 = FastAPIOAuth20(_linux_do_client, redirect_route_name='linux_do_login')
@router.get('', summary='获取 LinuxDo 授权链接') @router.get('', summary='获取 LinuxDo 授权链接')
async def linux_do_auth2() -> ResponseSchemaModel[str]: async def linux_do_oauth2(request: Request) -> ResponseSchemaModel[str]:
auth_url = await _linux_do_client.get_authorization_url(redirect_uri=admin_settings.OAUTH2_LINUX_DO_REDIRECT_URI) auth_url = await _linux_do_client.get_authorization_url(redirect_uri=f'{request.url}/callback')
return response_base.success(data=auth_url) return response_base.success(data=auth_url)
@@ -45,6 +45,6 @@ async def linux_do_login(
response=response, response=response,
background_tasks=background_tasks, background_tasks=background_tasks,
user=user, user=user,
social=UserSocialType.linuxdo, social=UserSocialType.linux_do,
) )
return RedirectResponse(url=f'{admin_settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}') return RedirectResponse(url=f'{admin_settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}')
+1 -7
View File
@@ -2,11 +2,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.sys.config import router as config_router
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.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.dict_data import router as dict_data_router
from backend.app.admin.api.v1.sys.dict_type import router as dict_type_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
@@ -16,14 +13,11 @@ from backend.app.admin.api.v1.sys.user import router as user_router
router = APIRouter(prefix='/sys') router = APIRouter(prefix='/sys')
router.include_router(config_router, prefix='/configs', tags=['系统配置'])
router.include_router(dept_router, prefix='/depts', tags=['系统部门']) router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
router.include_router(dict_data_router, prefix='/dict-datas', tags=['系统字典数据'])
router.include_router(dict_type_router, prefix='/dict-types', tags=['系统字典类型'])
router.include_router(menu_router, prefix='/menus', tags=['系统菜单']) router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
router.include_router(role_router, prefix='/roles', tags=['系统角色']) 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(token_router, prefix='/tokens', tags=['系统令牌']) router.include_router(token_router, prefix='/tokens', tags=['系统令牌'])
router.include_router(upload_router, prefix='/upload', tags=['系统上传']) router.include_router(upload_router, prefix='/upload', tags=['系统上传'])
router.include_router(plugin_router, prefix='/plugin', tags=['系统插件']) router.include_router(plugin_router, prefix='/plugin', tags=['系统插件'])
+24 -21
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, Request from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleDetail, UpdateDataRuleParam from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleDetail, UpdateDataRuleParam
from backend.app.admin.service.data_rule_service import data_rule_service from backend.app.admin.service.data_rule_service import data_rule_service
@@ -12,47 +12,48 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict, select_list_serialize
router = APIRouter() router = APIRouter()
@router.get('/models', summary='获取支持过滤的数据库模型', dependencies=[DependsJwtAuth]) @router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]: async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
models = await data_rule_service.get_models() models = await data_rule_service.get_models()
return response_base.success(data=models) return response_base.success(data=models)
@router.get('/model/{model}/columns', summary='获取支持过滤的数据库模型列', dependencies=[DependsJwtAuth]) @router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns(model: Annotated[str, Path()]) -> ResponseSchemaModel[list[str]]: async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[str]]:
models = await data_rule_service.get_columns(model=model) models = await data_rule_service.get_columns(model=model)
return response_base.success(data=models) return response_base.success(data=models)
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rule() -> ResponseSchemaModel[list[GetDataRuleDetail]]: async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data_rules = await data_rule_service.get_all() data = await data_rule_service.get_all()
data = select_list_serialize(data_rules)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据权限规则详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
async def get_data_rule(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetDataRuleDetail]: async def get_data_rule(
data_rule = await data_rule_service.get(pk=pk) pk: Annotated[int, Path(description='数据规则 ID')],
data = GetDataRuleDetail(**select_as_dict(data_rule)) ) -> ResponseSchemaModel[GetDataRuleDetail]:
data = await data_rule_service.get(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有数据权限规则', summary='分页获取所有数据规则',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_rule( async def get_pagination_data_rules(
db: CurrentSession, name: Annotated[str | None, Query()] = 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)
page_data = await paging_data(db, data_rule_select) page_data = await paging_data(db, data_rule_select)
@@ -61,7 +62,7 @@ async def get_pagination_data_rule(
@router.post( @router.post(
'', '',
summary='创建数据权限规则', summary='创建数据规则',
dependencies=[ dependencies=[
Depends(RequestPermission('data:rule:add')), Depends(RequestPermission('data:rule:add')),
DependsRBAC, DependsRBAC,
@@ -74,13 +75,15 @@ async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
@router.put( @router.put(
'/{pk}', '/{pk}',
summary='更新数据权限规则', summary='更新数据规则',
dependencies=[ dependencies=[
Depends(RequestPermission('data:rule:edit')), Depends(RequestPermission('data:rule:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
async def update_data_rule(pk: Annotated[int, Path(...)], obj: UpdateDataRuleParam) -> ResponseModel: async def update_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam
) -> ResponseModel:
count = await data_rule_service.update(pk=pk, obj=obj) count = await data_rule_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -89,14 +92,14 @@ async def update_data_rule(pk: Annotated[int, Path(...)], obj: UpdateDataRulePar
@router.delete( @router.delete(
'', '',
summary='批量删除数据权限规则', summary='批量删除数据规则',
dependencies=[ dependencies=[
Depends(RequestPermission('data:rule:del')), Depends(RequestPermission('data:rule:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_data_rule(request: Request, pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_data_rule(pk: Annotated[list[int], Query(description='数据规则 ID 列表')]) -> ResponseModel:
count = await data_rule_service.delete(request=request, pk=pk) count = await data_rule_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()
+11 -13
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service from backend.app.admin.service.dept_service import dept_service
@@ -10,24 +10,22 @@ 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.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
async def get_dept(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetDeptDetail]: async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseSchemaModel[GetDeptDetail]:
dept = await dept_service.get(pk=pk) data = await dept_service.get(pk=pk)
data = GetDeptDetail(**select_as_dict(dept))
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_tree( async def get_all_depts(
name: Annotated[str | None, Query()] = None, name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query()] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query()] = None, phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query()] = 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(name=name, leader=leader, phone=phone, status=status) dept = await dept_service.get_dept_tree(name=name, leader=leader, phone=phone, status=status)
return response_base.success(data=dept) return response_base.success(data=dept)
@@ -54,7 +52,7 @@ async def create_dept(obj: CreateDeptParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_dept(pk: Annotated[int, Path(...)], obj: UpdateDeptParam) -> ResponseModel: async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam) -> ResponseModel:
count = await dept_service.update(pk=pk, obj=obj) count = await dept_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -69,8 +67,8 @@ async def update_dept(pk: Annotated[int, Path(...)], obj: UpdateDeptParam) -> Re
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_dept(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def delete_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
count = await dept_service.delete(request=request, pk=pk) count = await dept_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()
+9 -10
View File
@@ -10,27 +10,26 @@ 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.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单展示树', dependencies=[DependsJwtAuth]) @router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth])
async def get_user_sidebar_tree(request: Request) -> ResponseSchemaModel[list[dict[str, Any]]]: async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_user_menu_tree(request=request) menu = await menu_service.get_user_menu_tree(request=request)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
async def get_menu(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetMenuDetail]: async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseSchemaModel[GetMenuDetail]:
menu = await menu_service.get(pk=pk) data = await menu_service.get(pk=pk)
data = GetMenuDetail(**select_as_dict(menu))
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_all_menus(
title: Annotated[str | None, Query()] = None, status: Annotated[int | None, Query()] = None title: Annotated[str | 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_menu_tree(title=title, status=status)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -57,7 +56,7 @@ async def create_menu(obj: CreateMenuParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_menu(pk: Annotated[int, Path(...)], obj: UpdateMenuParam) -> ResponseModel: async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam) -> ResponseModel:
count = await menu_service.update(pk=pk, obj=obj) count = await menu_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -72,8 +71,8 @@ async def update_menu(pk: Annotated[int, Path(...)], obj: UpdateMenuParam) -> Re
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_menu(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def delete_menu(pk: Annotated[int, Path(description='菜单 ID 列表')]) -> ResponseModel:
count = await menu_service.delete(request=request, pk=pk) count = await menu_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()
+10 -7
View File
@@ -52,19 +52,20 @@ async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path): if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装') raise errors.ForbiddenError(msg='此插件已安装')
os.makedirs(full_plugin_path) else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压安装 # 解压安装
members = [] members = []
for member in zf.infolist(): for member in zf.infolist():
if member.filename.startswith(plugin_dir_in_zip): if member.filename.startswith(plugin_dir_in_zip):
member.filename = member.filename.replace(plugin_dir_in_zip, '') new_filename = member.filename.replace(plugin_dir_in_zip, '')
if not member.filename: if new_filename:
continue member.filename = new_filename
members.append(member) members.append(member)
zf.extractall(PLUGIN_DIR, members) zf.extractall(PLUGIN_DIR, members)
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')): if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
await install_requirements_async(False) await install_requirements_async()
return response_base.success() return response_base.success()
@@ -77,10 +78,11 @@ async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def build_plugin_zip(plugin: Annotated[str, Query()]): async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse:
plugin_dir = os.path.join(PLUGIN_DIR, plugin) plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir): if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在') raise errors.ForbiddenError(msg='插件不存在')
bio = io.BytesIO() bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf: with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir): for root, dirs, files in os.walk(plugin_dir):
@@ -89,6 +91,7 @@ async def build_plugin_zip(plugin: Annotated[str, Query()]):
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir) arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, arcname) zf.write(file_path, arcname)
bio.seek(0) bio.seek(0)
return StreamingResponse( return StreamingResponse(
bio, bio,
+27 -24
View File
@@ -2,11 +2,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
GetRoleDetail, GetRoleDetail,
GetRoleWithRelationDetail,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleRuleParam, UpdateRoleRuleParam,
@@ -20,47 +21,49 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict, select_list_serialize
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
roles = await role_service.get_all() data = await role_service.get_all()
data = select_list_serialize(roles)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth]) @router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def get_user_all_roles(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_user_all_roles(
roles = await role_service.get_by_user(pk=pk) pk: Annotated[int, Path(description='用户 ID')],
data = select_list_serialize(roles) ) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_by_user(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth]) @router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
async def get_role_all_menus(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[list[dict[str, Any]]]: async def get_role_all_menus(
pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any]]]:
menu = await menu_service.get_role_menu_tree(pk=pk) menu = await menu_service.get_role_menu_tree(pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}/rules', summary='获取角色所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/{pk}/rules', summary='获取角色所有数据规则', dependencies=[DependsJwtAuth])
async def get_role_all_rules(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[list[int]]: async def get_role_all_rules(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
rule = await data_rule_service.get_role_rules(pk=pk) rule = await data_rule_service.get_role_rules(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(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetRoleDetail]: async def get_role(
role = await role_service.get(pk=pk) pk: Annotated[int, Path(description='角色 ID')],
data = GetRoleDetail(**select_as_dict(role)) ) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有角色', summary='分页获取所有角色',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -68,8 +71,8 @@ async def get_role(pk: Annotated[int, Path(...)]) -> ResponseSchemaModel[GetRole
) )
async def get_pagination_roles( async def get_pagination_roles(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query()] = None, name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetRoleDetail]]: ) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
role_select = await role_service.get_select(name=name, status=status) role_select = await role_service.get_select(name=name, status=status)
page_data = await paging_data(db, role_select) page_data = await paging_data(db, role_select)
@@ -97,7 +100,7 @@ async def create_role(obj: CreateRoleParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_role(pk: Annotated[int, Path(...)], obj: UpdateRoleParam) -> ResponseModel: async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam) -> ResponseModel:
count = await role_service.update(pk=pk, obj=obj) count = await role_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -113,9 +116,9 @@ async def update_role(pk: Annotated[int, Path(...)], obj: UpdateRoleParam) -> Re
], ],
) )
async def update_role_menus( async def update_role_menus(
request: Request, pk: Annotated[int, Path(...)], menu_ids: UpdateRoleMenuParam pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_menu(request=request, pk=pk, menu_ids=menu_ids) count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -123,16 +126,16 @@ async def update_role_menus(
@router.put( @router.put(
'/{pk}/rule', '/{pk}/rule',
summary='更新角色数据权限规则', summary='更新角色数据规则',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:rule:edit')), Depends(RequestPermission('sys:role:rule:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
async def update_role_rules( async def update_role_rules(
request: Request, pk: Annotated[int, Path(...)], rule_ids: UpdateRoleRuleParam pk: Annotated[int, Path(description='角色 ID')], rule_ids: UpdateRoleRuleParam
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_rule(request=request, pk=pk, rule_ids=rule_ids) count = await role_service.update_role_rule(pk=pk, rule_ids=rule_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -140,14 +143,14 @@ async def update_role_rules(
@router.delete( @router.delete(
'', '',
summary='批量删除角色', summary='批量删除角色',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:del')), Depends(RequestPermission('sys:role:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_role(request: Request, pk: Annotated[list[int], Query(...)]) -> ResponseModel: async def delete_role(pk: Annotated[list[int], Query(description='角色 ID 列表')]) -> ResponseModel:
count = await role_service.delete(request=request, pk=pk) count = await role_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()
+30 -25
View File
@@ -9,7 +9,7 @@ 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, KickOutToken
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, superuser_verify from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
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.conf import settings from backend.core.conf import settings
@@ -19,10 +19,28 @@ router = APIRouter()
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth]) @router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth])
async def get_tokens(username: Annotated[str | None, Query()] = None) -> ResponseSchemaModel[list[GetTokenDetail]]: async def get_tokens(
username: Annotated[str | None, Query(description='用户名')] = None,
) -> 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}:*')
token_online = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX) online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data = [] data: list[GetTokenDetail] = []
def append_token_detail() -> None:
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username', '未知'),
'nickname': extra_info.get('nickname', '未知'),
'ip': extra_info.get('ip', '未知'),
'os': extra_info.get('os', '未知'),
'browser': extra_info.get('browser', '未知'),
'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'),
}
)
)
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)
@@ -36,31 +54,16 @@ async def get_tokens(username: Annotated[str | None, Query()] = None) -> Respons
os='未知', os='未知',
browser='未知', browser='未知',
device='未知', device='未知',
status=StatusType.disable if session_uuid not in token_online else StatusType.enable, status=StatusType.enable if session_uuid in online_clients else StatusType.disable,
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}:{session_uuid}')
if extra_info: if extra_info:
def append_token_detail():
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username'),
'nickname': extra_info.get('nickname'),
'ip': extra_info.get('ip'),
'os': extra_info.get('os'),
'browser': extra_info.get('browser'),
'device': extra_info.get('device'),
'last_login_time': extra_info.get('last_login_time'),
}
)
)
extra_info = json.loads(extra_info) extra_info = json.loads(extra_info)
if extra_info.get('login_type') != 'swagger': # 排除 swagger 登录生成的 token
if username: if extra_info.get('swagger') is None:
if username is not None:
if username == extra_info.get('username'): if username == extra_info.get('username'):
append_token_detail() append_token_detail()
else: else:
@@ -78,7 +81,9 @@ async def get_tokens(username: Annotated[str | None, Query()] = None) -> Respons
DependsRBAC, DependsRBAC,
], ],
) )
async def kick_out(request: Request, pk: Annotated[int, Path(...)], session_uuid: KickOutToken) -> ResponseModel: async def kick_out(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: KickOutToken
) -> ResponseModel:
superuser_verify(request) superuser_verify(request)
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{pk}:{session_uuid}') await revoke_token(str(pk), obj.session_uuid)
return response_base.success() return response_base.success()
+30 -27
View File
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
AvatarParam, AvatarParam,
GetCurrentUserInfoDetail, GetCurrentUserInfoWithRelationDetail,
GetUserInfoDetail, GetUserInfoWithRelationDetail,
RegisterUserParam, RegisterUserParam,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
@@ -21,7 +21,6 @@ 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.database.db import CurrentSession from backend.database.db import CurrentSession
from backend.utils.serializers import select_as_dict
router = APIRouter() router = APIRouter()
@@ -33,10 +32,9 @@ async def register_user(obj: RegisterUserParam) -> ResponseModel:
@router.post('/add', summary='添加用户', dependencies=[DependsRBAC]) @router.post('/add', summary='添加用户', dependencies=[DependsRBAC])
async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]: async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.add(request=request, obj=obj) await user_service.add(request=request, obj=obj)
current_user = await user_service.get_userinfo(username=obj.username) data = await user_service.get_userinfo(username=obj.username)
data = GetUserInfoDetail(**select_as_dict(current_user))
return response_base.success(data=data) return response_base.success(data=data)
@@ -48,21 +46,24 @@ async def password_reset(request: Request, obj: ResetPasswordParam) -> ResponseM
return response_base.fail() return response_base.fail()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth], response_model_exclude={'password'}) @router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoDetail]: async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
data = GetCurrentUserInfoDetail(**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('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth])
async def get_user(username: Annotated[str, Path(...)]) -> ResponseSchemaModel[GetUserInfoDetail]: async def get_user(
current_user = await user_service.get_userinfo(username=username) username: Annotated[str, Path(description='用户名')],
data = GetUserInfoDetail(**select_as_dict(current_user)) ) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
data = await user_service.get_userinfo(username=username)
return response_base.success(data=data) return response_base.success(data=data)
@router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) @router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth])
async def update_user(request: Request, username: Annotated[str, Path(...)], obj: UpdateUserParam) -> ResponseModel: async def update_user(
request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserParam
) -> ResponseModel:
count = await user_service.update(request=request, username=username, obj=obj) count = await user_service.update(request=request, username=username, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -78,14 +79,16 @@ async def update_user(request: Request, username: Annotated[str, Path(...)], obj
], ],
) )
async def update_user_role( async def update_user_role(
request: Request, username: Annotated[str, Path(...)], obj: UpdateUserRoleParam request: Request, username: Annotated[str, Path(description='用户名')], obj: UpdateUserRoleParam
) -> ResponseModel: ) -> ResponseModel:
await user_service.update_roles(request=request, username=username, obj=obj) await user_service.update_roles(request=request, username=username, obj=obj)
return response_base.success() return response_base.success()
@router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth]) @router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth])
async def update_avatar(request: Request, username: Annotated[str, Path(...)], avatar: AvatarParam) -> ResponseModel: 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) count = await user_service.update_avatar(request=request, username=username, avatar=avatar)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -94,7 +97,7 @@ async def update_avatar(request: Request, username: Annotated[str, Path(...)], a
@router.get( @router.get(
'', '',
summary='(模糊条件)分页获取所有用户', summary='分页获取所有用户',
dependencies=[ dependencies=[
DependsJwtAuth, DependsJwtAuth,
DependsPagination, DependsPagination,
@@ -102,18 +105,18 @@ async def update_avatar(request: Request, username: Annotated[str, Path(...)], a
) )
async def get_pagination_users( async def get_pagination_users(
db: CurrentSession, db: CurrentSession,
dept: Annotated[int | None, Query()] = None, dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query()] = None, username: Annotated[str | None, Query(description='用户名')] = None,
phone: Annotated[str | None, Query()] = None, phone: Annotated[str | None, Query(description='手机号')] = None,
status: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]: ) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status) user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status)
page_data = await paging_data(db, user_select) page_data = await paging_data(db, user_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC]) @router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC])
async def super_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def super_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_permission(request=request, pk=pk) count = await user_service.update_permission(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -121,7 +124,7 @@ async def super_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
@router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC]) @router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC])
async def staff_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def staff_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_staff(request=request, pk=pk) count = await user_service.update_staff(request=request, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
@@ -129,15 +132,15 @@ async def staff_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
@router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC]) @router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC])
async def status_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def status_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_status(request=request, pk=pk) count = await user_service.update_status(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.put('/{pk}/multi', summary='修改用户多登录状态', dependencies=[DependsRBAC]) @router.put('/{pk}/multi', summary='修改用户多登录状态', dependencies=[DependsRBAC])
async def multi_set(request: Request, pk: Annotated[int, Path(...)]) -> ResponseModel: async def multi_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.update_multi_login(request=request, pk=pk) 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()
@@ -153,7 +156,7 @@ async def multi_set(request: Request, pk: Annotated[int, Path(...)]) -> Response
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_user(username: Annotated[str, Path(...)]) -> ResponseModel: async def delete_user(username: Annotated[str, Path(description='用户名')]) -> ResponseModel:
count = await user_service.delete(username=username) count = await user_service.delete(username=username)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+7 -15
View File
@@ -4,34 +4,26 @@ from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BasePath from backend.core.path_conf import BASE_PATH
class AdminSettings(BaseSettings): class AdminSettings(BaseSettings):
"""Admin Settings""" """Admin 配置"""
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore') model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# OAuth2https://github.com/fastapi-practices/fastapi_oauth20 # .env OAuth2
# GitHub
OAUTH2_GITHUB_CLIENT_ID: str OAUTH2_GITHUB_CLIENT_ID: str
OAUTH2_GITHUB_CLIENT_SECRET: str OAUTH2_GITHUB_CLIENT_SECRET: str
OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
# Linux Do
OAUTH2_LINUX_DO_CLIENT_ID: str OAUTH2_LINUX_DO_CLIENT_ID: str
OAUTH2_LINUX_DO_CLIENT_SECRET: str OAUTH2_LINUX_DO_CLIENT_SECRET: str
OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback'
# Front-end redirect address # OAuth2
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback' OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
# Captcha # 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha' CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒 CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
# Config
CONFIG_BUILT_IN_TYPES: list = ['website', 'protocol', 'policy']
@lru_cache @lru_cache
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.conf import admin_settings
from backend.app.admin.model import Config
from backend.app.admin.schema.config import CreateConfigParam, UpdateConfigParam
class CRUDConfig(CRUDPlus[Config]):
async def get(self, db: AsyncSession, pk: int) -> Config | None:
"""
获取系统参数配置
:param db:
:param pk:
:return:
"""
return await self.select_model_by_column(db, id=pk, type__not_in=admin_settings.CONFIG_BUILT_IN_TYPES)
async def get_by_type(self, db: AsyncSession, type: str) -> Sequence[Config]:
"""
通过 type 获取内置系统配置
: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:
"""
通过 name 和 type 获取内置系统配置
: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, built_in: bool = False) -> Config | None:
"""
通过 key 获取系统配置参数
:param db:
:param key:
:param built_in:
:return:
"""
filters = {'key': key}
if not built_in:
filters.update({'type__not_in': admin_settings.CONFIG_BUILT_IN_TYPES})
return await self.select_model_by_column(db, **filters)
async def get_list(self, name: str = None, type: str = None) -> Select:
"""
获取系统参数配置列表
:param name:
:param type:
:return:
"""
filters = {'type__not_in': admin_settings.CONFIG_BUILT_IN_TYPES}
if name is not None:
filters.update(name__like=f'%{name}%')
if type is not None:
filters.update(type__like=f'%{type}%')
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj_in: CreateConfigParam) -> None:
"""
创建 Config
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateConfigParam) -> int:
"""
更新 Config
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除 Config
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(
db, allow_multiple=True, id__in=pk, type__not_in=admin_settings.CONFIG_BUILT_IN_TYPES
)
config_dao: CRUDConfig = CRUDConfig(Config)
+36 -30
View File
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, desc, select from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload from sqlalchemy.orm import noload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
@@ -12,76 +12,82 @@ from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRu
class CRUDDataRule(CRUDPlus[DataRule]): class CRUDDataRule(CRUDPlus[DataRule]):
"""数据规则数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataRule | None: async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
""" """
获取数据权限规则 获取规则详情
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 ID
:return: :return:
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_list(self, name: str = None) -> Select: async def get_list(self, name: str | None) -> Select:
""" """
获取数据权限规则列表 获取规则列表
:param name: 规则名称
:return: :return:
""" """
stmt = select(self.model).options(noload(self.model.roles)).order_by(desc(self.model.created_time)) stmt = select(self.model).options(noload(self.model.roles)).order_by(desc(self.model.created_time))
where_list = []
filters = []
if name is not None: if name is not None:
where_list.append(self.model.name.like(f'%{name}%')) filters.append(self.model.name.like(f'%{name}%'))
if where_list:
stmt = stmt.where(*where_list) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_by_name(self, db: AsyncSession, name: str): async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
""" """
通过 name 获取数据权限规则 通过名称获取规则
:param db: :param db: 数据库会话
:param name: :param name: 规则名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name) return await self.select_model_by_column(db, name=name)
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]: async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
""" """
获取所有数据权限规则 获取所有规则
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.select_models(db) return await self.select_models(db)
async def create(self, db: AsyncSession, obj_in: CreateDataRuleParam) -> None: async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
""" """
创建数据权限规则 创建规则
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建规则参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDataRuleParam) -> int: async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
""" """
更新数据权限规则 更新规则
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 ID
:param obj_in: :param obj: 更新规则参数
:return: :return:
""" """
return await self.update_model(db, pk, obj_in) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除数据权限规则 删除规则
:param db: :param db: 数据库会话
:param pk: :param pk: 规则 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=pk)
+37 -37
View File
@@ -7,42 +7,44 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept, User from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
class CRUDDept(CRUDPlus[Dept]): class CRUDDept(CRUDPlus[Dept]):
"""部门数据库操作类"""
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None: async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
""" """
获取部门 获取部门详情
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
return await self.select_model_by_column(db, id=dept_id, del_flag=0) return await self.select_model_by_column(db, id=dept_id, del_flag=0)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None: async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
""" """
通过 name 获取 API 通过名称获取部门
:param db: :param db: 数据库会话
:param name: :param name: 部门名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name, del_flag=0) return await self.select_model_by_column(db, name=name, del_flag=0)
async def get_all( async def get_all(
self, db: AsyncSession, name: str = None, leader: str = None, phone: str = None, status: int = None self, db: AsyncSession, name: str | None, leader: str | None, phone: str | None, status: int | None
) -> Sequence[Dept]: ) -> Sequence[Dept]:
""" """
获取所有部门 获取所有部门
:param db: :param db: 数据库会话
:param name: :param name: 部门名称
:param leader: :param leader: 负责人
:param phone: :param phone: 联系电话
:param status: :param status: 部门状态
:return: :return:
""" """
filters = {'del_flag__eq': 0} filters = {'del_flag__eq': 0}
@@ -56,62 +58,60 @@ class CRUDDept(CRUDPlus[Dept]):
filters.update(status=status) filters.update(status=status)
return await self.select_models_order(db, sort_columns='sort', **filters) return await self.select_models_order(db, sort_columns='sort', **filters)
async def create(self, db: AsyncSession, obj_in: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
创建部门 创建部门
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建部门参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db: AsyncSession, dept_id: int, obj_in: UpdateDeptParam) -> int: async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
""" """
更新部门 更新部门
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:param obj_in: :param obj: 更新部门参数
:return: :return:
""" """
return await self.update_model(db, dept_id, obj_in) return await self.update_model(db, dept_id, obj)
async def delete(self, db: AsyncSession, dept_id: int) -> int: async def delete(self, db: AsyncSession, dept_id: int) -> int:
""" """
删除部门 删除部门
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag') return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
async def get_with_relation(self, db: AsyncSession, dept_id: int) -> list[User]: async def get_with_relation(self, db: AsyncSession, dept_id: int) -> Dept | None:
""" """
获取关联 获取部门及关联数据
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id) stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id)
result = await db.execute(stmt) result = await db.execute(stmt)
user_relation = result.scalars().first() return result.scalars().first()
return user_relation.users
async def get_children(self, db: AsyncSession, dept_id: int) -> list[Dept]: async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
""" """
获取子部门 获取子部门列表
:param db: :param db: 数据库会话
:param dept_id: :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == dept_id) stmt = select(self.model).where(self.model.parent_id == dept_id, self.model.del_flag == 0)
result = await db.execute(stmt) result = await db.execute(stmt)
dept = result.scalars().first() return result.scalars().all()
return dept.children
dept_dao: CRUDDept = CRUDDept(Dept) dept_dao: CRUDDept = CRUDDept(Dept)
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DictData
from backend.app.admin.schema.dict_data import CreateDictDataParam, UpdateDictDataParam
class CRUDDictData(CRUDPlus[DictData]):
async def get(self, db: AsyncSession, pk: int) -> DictData | None:
"""
获取字典数据
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, label: str = None, value: str = None, status: int = None) -> Select:
"""
获取所有字典数据
:param label:
:param value:
:param status:
:return:
"""
stmt = select(self.model).options(noload(self.model.type)).order_by(desc(self.model.sort))
where_list = []
if label is not None:
where_list.append(self.model.label.like(f'%{label}%'))
if value is not None:
where_list.append(self.model.value.like(f'%{value}%'))
if status is not None:
where_list.append(self.model.status == status)
if where_list:
stmt = stmt.where(and_(*where_list))
return stmt
async def get_by_label(self, db: AsyncSession, label: str) -> DictData | None:
"""
通过 label 获取字典数据
:param db:
:param label:
:return:
"""
return await self.select_model_by_column(db, label=label)
async def create(self, db: AsyncSession, obj_in: CreateDictDataParam) -> None:
"""
创建数据字典
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDictDataParam) -> int:
"""
更新数据字典
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除字典数据
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DictData | None:
"""
获取字典数据和类型
:param db:
:param pk:
:return:
"""
stmt = select(self.model).options(selectinload(self.model.type)).where(self.model.id == pk)
dict_data = await db.execute(stmt)
return dict_data.scalars().first()
dict_data_dao: CRUDDictData = CRUDDictData(DictData)
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DictType
from backend.app.admin.schema.dict_type import CreateDictTypeParam, UpdateDictTypeParam
class CRUDDictType(CRUDPlus[DictType]):
async def get(self, db: AsyncSession, pk: int) -> DictType | None:
"""
获取字典类型
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, *, name: str = None, code: str = None, status: int = None) -> Select:
"""
获取所有字典类型
:param name:
:param code:
:param status:
:return:
"""
filters = {}
if name is not None:
filters.update(name__like=f'%{name}%')
if code is not None:
filters.update(code__like=f'%{code}%')
if status is not None:
filters.update(status=status)
return await self.select_order('created_time', 'desc', **filters)
async def get_by_code(self, db: AsyncSession, code: str) -> DictType | None:
"""
通过 code 获取字典类型
:param db:
:param code:
:return:
"""
return await self.select_model_by_column(db, code=code)
async def create(self, db: AsyncSession, obj_in: CreateDictTypeParam) -> None:
"""
创建字典类型
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateDictTypeParam) -> int:
"""
更新字典类型
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
"""
删除字典类型
:param db:
:param pk:
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk)
dict_type_dao: CRUDDictType = CRUDDictType(DictType)
+14 -12
View File
@@ -9,13 +9,15 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam
class CRUDLoginLog(CRUDPlus[LoginLog]): class CRUDLoginLog(CRUDPlus[LoginLog]):
async def get_list(self, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: """登录日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取登录日志列表 获取登录日志列表
:param username: :param username: 用户名
:param status: :param status: 登录状态
:param ip: :param ip: IP 地址
:return: :return:
""" """
filters = {} filters = {}
@@ -27,31 +29,31 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
filters.update(ip__like=f'%{ip}%') filters.update(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_in: CreateLoginLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
""" """
创建登录日志 创建登录日志
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建登录日志参数
:return: :return:
""" """
await self.create_model(db, obj_in, 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, pk: list[int]) -> int:
""" """
删除登录日志 删除登录日志
:param db: :param db: 数据库会话
:param pk: :param pk: 登录日志 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=pk)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
删除所有登录日志 删除所有日志
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) return await self.delete_model_by_column(db, allow_multiple=True)
+42 -39
View File
@@ -3,6 +3,7 @@
from typing import Sequence from typing import Sequence
from sqlalchemy import and_, asc, select from sqlalchemy import and_, asc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
@@ -11,33 +12,35 @@ from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
class CRUDMenu(CRUDPlus[Menu]): class CRUDMenu(CRUDPlus[Menu]):
async def get(self, db, menu_id: int) -> Menu | None: """菜单数据库操作类"""
"""
获取菜单
:param db: async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
:param menu_id: """
获取菜单详情
:param db: 数据库会话
:param menu_id: 菜单 ID
:return: :return:
""" """
return await self.select_model(db, menu_id) return await self.select_model(db, menu_id)
async def get_by_title(self, db, title: str) -> Menu | None: async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
""" """
通过 title 获取菜单 通过标题获取菜单
:param db: :param db: 数据库会话
:param title: :param title: 菜单标题
:return: :return:
""" """
return await self.select_model_by_column(db, title=title, menu_type__ne=2) return await self.select_model_by_column(db, title=title, type__ne=2)
async def get_all(self, db, title: str | None = None, status: int | None = None) -> Sequence[Menu]: async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
""" """
获取所有菜单 获取菜单列表
:param db: :param db: 数据库会话
:param title: :param title: 菜单标题
:param status: :param status: 菜单状态
:return: :return:
""" """
filters = {} filters = {}
@@ -47,60 +50,60 @@ class CRUDMenu(CRUDPlus[Menu]):
filters.update(status=status) filters.update(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, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]: async def get_role_menus(self, db: AsyncSession, superuser: bool, menu_ids: list[int]) -> Sequence[Menu]:
""" """
获取角色菜单 获取角色菜单列表
:param db: :param db: 数据库会话
:param superuser: :param superuser: 是否超级管理员
:param menu_ids: :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
stmt = select(self.model).order_by(asc(self.model.sort)) stmt = select(self.model).order_by(asc(self.model.sort))
where_list = [self.model.menu_type.in_([0, 1])] filters = [self.model.type.in_([0, 1])]
if not superuser: if not superuser:
where_list.append(self.model.id.in_(menu_ids)) filters.append(self.model.id.in_(menu_ids))
stmt = stmt.where(and_(*where_list)) stmt = stmt.where(and_(*filters))
menu = await db.execute(stmt) menu = await db.execute(stmt)
return menu.scalars().all() return menu.scalars().all()
async def create(self, db, obj_in: CreateMenuParam) -> None: async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
""" """
创建菜单 创建菜单
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建菜单参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db, menu_id: int, obj_in: UpdateMenuParam) -> int: async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
""" """
更新菜单 更新菜单
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:param obj_in: :param obj: 更新菜单参数
:return: :return:
""" """
return await self.update_model(db, menu_id, obj_in) return await self.update_model(db, menu_id, obj)
async def delete(self, db, menu_id: int) -> int: async def delete(self, db: AsyncSession, menu_id: int) -> int:
""" """
删除菜单 删除菜单
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:return: :return:
""" """
return await self.delete_model(db, menu_id) return await self.delete_model(db, menu_id)
async def get_children(self, db, menu_id: int) -> list[Menu]: async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
""" """
获取子菜单 获取子菜单列表
:param db: :param db: 数据库会话
:param menu_id: :param menu_id: 菜单 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id) stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id)
+16 -14
View File
@@ -9,49 +9,51 @@ from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]): class CRUDOperaLogDao(CRUDPlus[OperaLog]):
async def get_list(self, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: """操作日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取操作日志列表 获取操作日志列表
:param username: :param username: 用户名
:param status: :param status: 操作状态
:param ip: :param ip: IP 地址
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username=f'%{username}%') filters.update(username__like=f'%{username}%')
if status is not None: if status is not None:
filters.update(status=status) filters.update(status=status)
if ip is not None: if ip is not None:
filters.update(ip=f'%{ip}%') filters.update(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_in: CreateOperaLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
""" """
创建操作日志 创建操作日志
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建操作日志参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pk: list[int]) -> int:
""" """
删除操作日志 删除操作日志
:param db: :param db: 数据库会话
:param pk: :param pk: 操作日志 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=pk)
async def delete_all(self, db: AsyncSession) -> int: async def delete_all(self, db: AsyncSession) -> int:
""" """
删除所有操作日志 删除所有日志
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) return await self.delete_model_by_column(db, allow_multiple=True)
+56 -52
View File
@@ -2,7 +2,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from sqlalchemy import Select, desc, select from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
@@ -16,22 +17,24 @@ from backend.app.admin.schema.role import (
class CRUDRole(CRUDPlus[Role]): class CRUDRole(CRUDPlus[Role]):
async def get(self, db, role_id: int) -> Role | None: """角色数据库操作类"""
"""
获取角色
:param db: async def get(self, db: AsyncSession, role_id: int) -> Role | None:
:param role_id: """
获取角色详情
:param db: 数据库会话
:param role_id: 角色 ID
:return: :return:
""" """
return await self.select_model(db, role_id) return await self.select_model(db, role_id)
async def get_with_relation(self, db, role_id: int) -> Role | None: async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
""" """
获取角色和菜单 获取角色及关联数据
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:return: :return:
""" """
stmt = ( stmt = (
@@ -42,33 +45,33 @@ class CRUDRole(CRUDPlus[Role]):
role = await db.execute(stmt) role = await db.execute(stmt)
return role.scalars().first() return role.scalars().first()
async def get_all(self, db) -> Sequence[Role]: async def get_all(self, db: AsyncSession) -> Sequence[Role]:
""" """
获取所有角色 获取所有角色
:param db: :param db: 数据库会话
:return: :return:
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_by_user(self, db, user_id: int) -> Sequence[Role]: async def get_by_user(self, db: AsyncSession, user_id: int) -> Sequence[Role]:
""" """
获取用户所有角色 获取用户角色列表
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
stmt = select(self.model).join(self.model.users).where(User.id == user_id) stmt = select(self.model).join(self.model.users).where(User.id == user_id)
roles = await db.execute(stmt) roles = await db.execute(stmt)
return roles.scalars().all() 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:
""" """
获取角色列表 获取角色列表
:param name: :param name: 角色名称
:param status: :param status: 角色状态
:return: :return:
""" """
stmt = ( stmt = (
@@ -76,84 +79,85 @@ class CRUDRole(CRUDPlus[Role]):
.options(noload(self.model.users), noload(self.model.menus), noload(self.model.rules)) .options(noload(self.model.users), noload(self.model.menus), noload(self.model.rules))
.order_by(desc(self.model.created_time)) .order_by(desc(self.model.created_time))
) )
where_list = []
if name: filters = []
where_list.append(self.model.name.like(f'%{name}%')) if name is not None:
filters.append(self.model.name.like(f'%{name}%'))
if status is not None: if status is not None:
where_list.append(self.model.status == status) filters.append(self.model.status == status)
if where_list:
stmt = stmt.where(*where_list) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_by_name(self, db, name: str) -> Role | None: async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
""" """
通过 name 获取角色 通过名称获取角色
:param db: :param db: 数据库会话
:param name: :param name: 角色名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name) return await self.select_model_by_column(db, name=name)
async def create(self, db, obj_in: CreateRoleParam) -> None: async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
""" """
创建角色 创建角色
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建角色参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def update(self, db, role_id: int, obj_in: UpdateRoleParam) -> int: async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
""" """
更新角色 更新角色
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:param obj_in: :param obj: 更新角色参数
:return: :return:
""" """
return await self.update_model(db, role_id, obj_in) return await self.update_model(db, role_id, obj)
async def update_menus(self, db, role_id: int, menu_ids: UpdateRoleMenuParam) -> int: async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
""" """
更新角色菜单 更新角色菜单
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:param menu_ids: :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
current_role = await self.get_with_relation(db, role_id) current_role = await self.get_with_relation(db, role_id)
# 更新菜单
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus)) stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
menus = await db.execute(stmt) menus = await db.execute(stmt)
current_role.menus = menus.scalars().all() current_role.menus = menus.scalars().all()
return len(current_role.menus) return len(current_role.menus)
async def update_rules(self, db, role_id: int, rule_ids: UpdateRoleRuleParam) -> int: async def update_rules(self, db: AsyncSession, role_id: int, rule_ids: UpdateRoleRuleParam) -> int:
""" """
更新角色数据权限 更新角色数据规则
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 ID
:param rule_ids: :param rule_ids: 权限规则 ID 列表
:return: :return:
""" """
current_role = await self.get_with_relation(db, role_id) current_role = await self.get_with_relation(db, role_id)
# 更新数据权限
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
rules = await db.execute(stmt) rules = await db.execute(stmt)
current_role.rules = rules.scalars().all() current_role.rules = rules.scalars().all()
return len(current_role.rules) return len(current_role.rules)
async def delete(self, db, role_id: list[int]) -> int: async def delete(self, db: AsyncSession, role_id: list[int]) -> int:
""" """
删除角色 删除角色
:param db: :param db: 数据库会话
:param role_id: :param role_id: 角色 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_id)
+101 -91
View File
@@ -21,42 +21,44 @@ from backend.utils.timezone import timezone
class CRUDUser(CRUDPlus[User]): class CRUDUser(CRUDPlus[User]):
"""用户数据库操作类"""
async def get(self, db: AsyncSession, user_id: int) -> User | None: async def get(self, db: AsyncSession, user_id: int) -> User | None:
""" """
获取用户 获取用户详情
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
return await self.select_model(db, user_id) return await self.select_model(db, user_id)
async def get_by_username(self, db: AsyncSession, username: str) -> User | None: async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
""" """
通过 username 获取用户 通过用户名获取用户
:param db: :param db: 数据库会话
:param username: :param username: 用户名
:return: :return:
""" """
return await self.select_model_by_column(db, username=username) return await self.select_model_by_column(db, username=username)
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None: async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
""" """
通过 nickname 获取用户 通过昵称获取用户
:param db: :param db: 数据库会话
:param nickname: :param nickname: 用户昵称
:return: :return:
""" """
return await self.select_model_by_column(db, nickname=nickname) return await self.select_model_by_column(db, nickname=nickname)
async def update_login_time(self, db: AsyncSession, username: str) -> int: async def update_login_time(self, db: AsyncSession, username: str) -> int:
""" """
更新用户登录时间 更新用户最后登录时间
:param db: :param db: 数据库会话
:param username: :param username: 用户名
:return: :return:
""" """
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)
@@ -65,9 +67,9 @@ class CRUDUser(CRUDPlus[User]):
""" """
创建用户 创建用户
:param db: :param db: 数据库会话
:param obj: :param obj: 注册用户参数
:param social: 社交用户适配 oauth 2.0 :param social: 是否社交用户
:return: :return:
""" """
if not social: if not social:
@@ -83,10 +85,10 @@ class CRUDUser(CRUDPlus[User]):
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
后台添加用户 添加用户
:param db: :param db: 数据库会话
:param obj: :param obj: 添加用户参数
:return: :return:
""" """
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
@@ -94,19 +96,21 @@ class CRUDUser(CRUDPlus[User]):
dict_obj = obj.model_dump(exclude={'roles'}) dict_obj = obj.model_dump(exclude={'roles'})
dict_obj.update({'salt': salt}) dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
role_list = [] role_list = []
for role_id in obj.roles: for role_id in obj.roles:
role_list.append(await db.get(Role, role_id)) role_list.append(await db.get(Role, role_id))
new_user.roles.extend(role_list) 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 update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int:
""" """
更新用户信息 更新用户信息
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户 ID
:param obj: :param obj: 更新用户参数
:return: :return:
""" """
return await self.update_model(db, input_user, obj) return await self.update_model(db, input_user, obj)
@@ -116,15 +120,14 @@ class CRUDUser(CRUDPlus[User]):
""" """
更新用户角色 更新用户角色
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户对象
:param obj: :param obj: 更新角色参数
:return: :return:
""" """
# 删除用户所有角色
for i in list(input_user.roles): for i in list(input_user.roles):
input_user.roles.remove(i) input_user.roles.remove(i)
# 添加用户角色
role_list = [] role_list = []
for role_id in obj.roles: for role_id in obj.roles:
role_list.append(await db.get(Role, role_id)) role_list.append(await db.get(Role, role_id))
@@ -134,29 +137,29 @@ class CRUDUser(CRUDPlus[User]):
""" """
更新用户头像 更新用户头像
:param db: :param db: 数据库会话
:param input_user: :param input_user: 用户 ID
:param avatar: :param avatar: 头像地址
:return: :return:
""" """
return await self.update_model(db, input_user, {'avatar': avatar.url}) return await self.update_model(db, input_user, {'avatar': str(avatar.url)})
async def delete(self, db: AsyncSession, user_id: int) -> int: async def delete(self, db: AsyncSession, user_id: int) -> int:
""" """
删除用户 删除用户
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
return await self.delete_model(db, user_id) return await self.delete_model(db, user_id)
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: 电子邮箱
:return: :return:
""" """
return await self.select_model_by_column(db, email=email) return await self.select_model_by_column(db, email=email)
@@ -165,21 +168,21 @@ class CRUDUser(CRUDPlus[User]):
""" """
重置用户密码 重置用户密码
:param db: :param db: 数据库会话
:param pk: :param pk: 用户 ID
:param new_pwd: :param new_pwd: 新密码已加密
:return: :return:
""" """
return await self.update_model(db, pk, {'password': new_pwd}) return await self.update_model(db, pk, {'password': new_pwd})
async def get_list(self, dept: int = None, username: str = None, phone: str = None, status: int = None) -> Select: async def get_list(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
""" """
获取用户列表 获取用户列表
:param dept: :param dept: 部门 ID
:param username: :param username: 用户名
:param phone: :param phone: 电话号码
:param status: :param status: 用户状态
:return: :return:
""" """
stmt = ( stmt = (
@@ -191,25 +194,28 @@ class CRUDUser(CRUDPlus[User]):
) )
.order_by(desc(self.model.join_time)) .order_by(desc(self.model.join_time))
) )
where_list = []
filters = []
if dept: if dept:
where_list.append(self.model.dept_id == dept) filters.append(self.model.dept_id == dept)
if username: if username:
where_list.append(self.model.username.like(f'%{username}%')) filters.append(self.model.username.like(f'%{username}%'))
if phone: if phone:
where_list.append(self.model.phone.like(f'%{phone}%')) filters.append(self.model.phone.like(f'%{phone}%'))
if status is not None: if status is not None:
where_list.append(self.model.status == status) filters.append(self.model.status == status)
if where_list:
stmt = stmt.where(and_(*where_list)) if filters:
stmt = stmt.where(and_(*filters))
return stmt return stmt
async def get_super(self, db: AsyncSession, user_id: int) -> bool: async def get_super(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户超级管理员状态 获取用户是否为超级管理员
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -217,10 +223,10 @@ class CRUDUser(CRUDPlus[User]):
async def get_staff(self, db: AsyncSession, user_id: int) -> bool: async def get_staff(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户后台登录状态 获取用户是否可以登录后台
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -230,8 +236,8 @@ class CRUDUser(CRUDPlus[User]):
""" """
获取用户状态 获取用户状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
@@ -239,81 +245,85 @@ class CRUDUser(CRUDPlus[User]):
async def get_multi_login(self, db: AsyncSession, user_id: int) -> bool: async def get_multi_login(self, db: AsyncSession, user_id: int) -> bool:
""" """
获取用户多点登录状态 获取用户是否允许多端登录
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
user = await self.get(db, user_id) user = await self.get(db, user_id)
return user.is_multi_login return user.is_multi_login
async def set_super(self, db: AsyncSession, user_id: int, _super: bool) -> int: async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int:
""" """
设置用户超级管理员 设置用户超级管理员状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param _super: :param is_super: 是否超级管理员
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_superuser': _super}) return await self.update_model(db, user_id, {'is_superuser': is_super})
async def set_staff(self, db: AsyncSession, user_id: int, staff: bool) -> int: async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int:
""" """
设置用户后台登录 设置用户后台登录状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param staff: :param is_staff: 是否可登录后台
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_staff': staff}) return await self.update_model(db, user_id, {'is_staff': is_staff})
async def set_status(self, db: AsyncSession, user_id: int, status: bool) -> int: async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
""" """
设置用户状态 设置用户状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param status: :param status: 状态
:return: :return:
""" """
return await self.update_model(db, user_id, {'status': status}) return await self.update_model(db, user_id, {'status': status})
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int: async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int:
""" """
设置用户多登录 设置用户多登录状态
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param multi_login: :param multi_login: 是否允许多端登录
:return: :return:
""" """
return await self.update_model(db, user_id, {'is_multi_login': multi_login}) return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def get_with_relation(self, db: AsyncSession, *, user_id: int = None, username: str = None) -> User | None: async def get_with_relation(
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None
) -> User | None:
""" """
获取用户部门角色菜单规则 获取用户关联信息
:param db: :param db: 数据库会话
:param user_id: :param user_id: 用户 ID
:param username: :param username: 用户名
:return: :return:
""" """
stmt = select(self.model).options( stmt = select(self.model).options(
selectinload(self.model.dept), selectinload(self.model.dept),
selectinload(self.model.roles).options( selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.rules)),
selectinload(Role.menus),
selectinload(Role.rules),
),
) )
filters = [] filters = []
if user_id: if user_id:
filters.append(self.model.id == user_id) filters.append(self.model.id == user_id)
if username: if username:
filters.append(self.model.username == username) filters.append(self.model.username == username)
user = await db.execute(stmt.where(*filters))
if filters:
stmt = stmt.where(and_(*filters))
user = await db.execute(stmt)
return user.scalars().first() return user.scalars().first()
+14 -12
View File
@@ -8,37 +8,39 @@ from backend.app.admin.schema.user_social import CreateUserSocialParam
from backend.common.enums import UserSocialType from backend.common.enums import UserSocialType
class CRUDOUserSocial(CRUDPlus[UserSocial]): class CRUDUserSocial(CRUDPlus[UserSocial]):
"""用户社交账号数据库操作类"""
async def get(self, db: AsyncSession, pk: int, source: UserSocialType) -> UserSocial | None: async def get(self, db: AsyncSession, pk: int, source: UserSocialType) -> UserSocial | None:
""" """
获取用户社交账号绑定 获取用户社交账号绑定详情
:param db: :param db: 数据库会话
:param pk: :param pk: 用户 ID
:param source: :param source: 社交账号类型
:return: :return:
""" """
return await self.select_model_by_column(db, user_id=pk, source=source) return await self.select_model_by_column(db, user_id=pk, source=source)
async def create(self, db: AsyncSession, obj_in: CreateUserSocialParam) -> None: async def create(self, db: AsyncSession, obj: CreateUserSocialParam) -> None:
""" """
创建用户社交账号绑定 创建用户社交账号绑定
:param db: :param db: 数据库会话
:param obj_in: :param obj: 创建用户社交账号绑定参数
:return: :return:
""" """
await self.create_model(db, obj_in) await self.create_model(db, obj)
async def delete(self, db: AsyncSession, social_id: int) -> int: async def delete(self, db: AsyncSession, social_id: int) -> int:
""" """
删除用户社交账号绑定 删除用户社交账号绑定
:param db: :param db: 数据库会话
:param social_id: :param social_id: 社交账号绑定 ID
:return: :return:
""" """
return await self.delete_model(db, social_id) return await self.delete_model(db, social_id)
user_social_dao: CRUDOUserSocial = CRUDOUserSocial(UserSocial) user_social_dao: CRUDUserSocial = CRUDUserSocial(UserSocial)
-3
View File
@@ -1,10 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from backend.app.admin.model.config import Config
from backend.app.admin.model.data_rule import DataRule from backend.app.admin.model.data_rule import DataRule
from backend.app.admin.model.dept import Dept from backend.app.admin.model.dept import Dept
from backend.app.admin.model.dict_data import DictData
from backend.app.admin.model.dict_type import DictType
from backend.app.admin.model.login_log import LoginLog from backend.app.admin.model.login_log import LoginLog
from backend.app.admin.model.menu import Menu 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
+9 -2
View File
@@ -1,14 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
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_rule from backend.app.admin.model.m2m import sys_role_data_rule
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import Role
class DataRule(Base): class DataRule(Base):
"""数据权限规则表""" """数据规则表"""
__tablename__ = 'sys_data_rule' __tablename__ = 'sys_data_rule'
@@ -23,4 +30,4 @@ class DataRule(Base):
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 角色规则多对多 # 角色规则多对多
roles: Mapped[list['Role']] = relationship(init=False, secondary=sys_role_data_rule, back_populates='rules') # noqa: F821 roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='rules')
+9 -4
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Union from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, String from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER from sqlalchemy.dialects.postgresql import INTEGER
@@ -8,6 +10,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class Dept(Base): class Dept(Base):
"""部门表""" """部门表"""
@@ -29,8 +34,8 @@ 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' ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
) )
parent: Mapped[Union['Dept', None]] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list['Dept'] | None] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
# 部门用户一对多 # 部门用户一对多
users: Mapped[list['User']] = relationship(init=False, back_populates='dept') # noqa: F821 users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
+1 -1
View File
@@ -30,6 +30,6 @@ sys_role_data_rule = Table(
Integer, Integer,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'), ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
primary_key=True, primary_key=True,
comment='数据权限规则ID', comment='数据规则ID',
), ),
) )
+14 -6
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Union from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
@@ -10,6 +12,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_menu from backend.app.admin.model.m2m import sys_role_menu
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import Role
class Menu(Base): class Menu(Base):
"""菜单表""" """菜单表"""
@@ -19,15 +24,18 @@ 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='路由地址')
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='菜单图标')
path: Mapped[str | None] = mapped_column(String(200), default=None, comment='路由地址') type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)')
menu_type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮)')
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正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)') display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)') cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='外链地址'
)
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='备注'
) )
@@ -36,8 +44,8 @@ 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' ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
) )
parent: Mapped[Union['Menu', None]] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list['Menu'] | None] = relationship(init=False, back_populates='parent') children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent')
# 菜单角色多对多 # 菜单角色多对多
roles: Mapped[list['Role']] = relationship(init=False, secondary=sys_role_menu, back_populates='menus') # noqa: F821 roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
+2 -1
View File
@@ -3,7 +3,8 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, String
from sqlalchemy.dialects.mysql import JSON, LONGTEXT, TEXT from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, id_key from backend.common.model import DataClassBase, id_key
+11 -4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import 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
@@ -8,6 +12,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_rule, sys_role_menu, sys_user_role from backend.app.admin.model.m2m import sys_role_data_rule, sys_role_menu, sys_user_role
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Menu, User
class Role(Base): class Role(Base):
"""角色表""" """角色表"""
@@ -22,10 +29,10 @@ class Role(Base):
) )
# 角色用户多对多 # 角色用户多对多
users: Mapped[list['User']] = relationship(init=False, secondary=sys_user_role, back_populates='roles') # noqa: F821 users: Mapped[list[User]] = relationship(init=False, secondary=sys_user_role, back_populates='roles')
# 角色菜单多对多 # 角色菜单多对多
menus: Mapped[list['Menu']] = relationship(init=False, secondary=sys_role_menu, back_populates='roles') # noqa: F821 menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
# 角色数据权限规则多对多 # 角色数据规则多对多
rules: Mapped[list['DataRule']] = relationship(init=False, secondary=sys_role_data_rule, back_populates='roles') # noqa: F821 rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_role_data_rule, back_populates='roles')
+10 -5
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Union from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
@@ -12,6 +14,9 @@ from backend.common.model import Base, id_key
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role, UserSocial
class User(Base): class User(Base):
"""用户表""" """用户表"""
@@ -31,7 +36,7 @@ class User(Base):
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, 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是)'
) )
@@ -48,10 +53,10 @@ class User(Base):
dept_id: Mapped[int | None] = mapped_column( dept_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID' ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID'
) )
dept: Mapped[Union['Dept', None]] = relationship(init=False, back_populates='users') # noqa: F821 dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户社交信息一对多 # 用户社交信息一对多
socials: Mapped[list['UserSocial']] = relationship(init=False, back_populates='user') # noqa: F821 socials: Mapped[list[UserSocial]] = relationship(init=False, back_populates='user')
# 用户角色多对多 # 用户角色多对多
roles: Mapped[list['Role']] = relationship(init=False, secondary=sys_user_role, back_populates='users') # noqa: F821 roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
+7 -2
View File
@@ -1,12 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Union from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class UserSocial(Base): class UserSocial(Base):
"""用户社交表(OAuth2""" """用户社交表(OAuth2"""
@@ -25,4 +30,4 @@ class UserSocial(Base):
user_id: Mapped[int | None] = mapped_column( user_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_user.id', ondelete='SET NULL'), default=None, comment='用户关联ID' ForeignKey('sys_user.id', ondelete='SET NULL'), default=None, comment='用户关联ID'
) )
user: Mapped[Union['User', None]] = relationship(init=False, back_populates='socials') # noqa: F821 user: Mapped[User | None] = relationship(init=False, back_populates='socials')
+2
View File
@@ -6,5 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase): class GetCaptchaDetail(SchemaBase):
"""验证码详情"""
image_type: str = Field(description='图片类型') image_type: str = Field(description='图片类型')
image: str = Field(description='图片内容') image: str = Field(description='图片内容')
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict
from backend.common.schema import SchemaBase
class SaveBuiltInConfigParam(SchemaBase):
name: str
key: str
value: str
class ConfigSchemaBase(SchemaBase):
name: str
type: str | None
key: str
value: str
is_frontend: bool
remark: str | None
class CreateConfigParam(ConfigSchemaBase):
pass
class UpdateConfigParam(ConfigSchemaBase):
pass
class GetConfigDetail(ConfigSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
+17 -12
View File
@@ -9,28 +9,33 @@ from backend.common.schema import SchemaBase
class DataRuleSchemaBase(SchemaBase): class DataRuleSchemaBase(SchemaBase):
name: str """数据规则基础模型"""
model: str
column: str name: str = Field(description='规则名称')
operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR) model: str = Field(description='模型名称')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq) column: str = Field(description='字段名称')
value: str operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.OR, description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型')
value: str = Field(description='规则值')
class CreateDataRuleParam(DataRuleSchemaBase): class CreateDataRuleParam(DataRuleSchemaBase):
pass """创建数据规则参数"""
class UpdateDataRuleParam(DataRuleSchemaBase): class UpdateDataRuleParam(DataRuleSchemaBase):
pass """更新数据规则参数"""
class GetDataRuleDetail(DataRuleSchemaBase): class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='规则 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
def __hash__(self): def __hash__(self) -> int:
"""计算哈希值"""
return hash(self.name) return hash(self.name)
+17 -13
View File
@@ -9,27 +9,31 @@ from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
class DeptSchemaBase(SchemaBase): class DeptSchemaBase(SchemaBase):
name: str """部门基础模型"""
parent_id: int | None = Field(default=None, description='部门父级ID')
sort: int = Field(default=0, ge=0, description='排序') name: str = Field(description='部门名称')
leader: str | None = None parent_id: int | None = Field(None, description='部门父级 ID')
phone: CustomPhoneNumber | None = None sort: int = Field(0, ge=0, description='排序')
email: CustomEmailStr | None = None leader: str | None = Field(None, description='负责人')
status: StatusType = Field(default=StatusType.enable) phone: CustomPhoneNumber | None = Field(None, description='联系电话')
email: CustomEmailStr | None = Field(None, description='邮箱')
status: StatusType = Field(StatusType.enable, description='状态')
class CreateDeptParam(DeptSchemaBase): class CreateDeptParam(DeptSchemaBase):
pass """创建部门参数"""
class UpdateDeptParam(DeptSchemaBase): class UpdateDeptParam(DeptSchemaBase):
pass """更新部门参数"""
class GetDeptDetail(DeptSchemaBase): class GetDeptDetail(DeptSchemaBase):
"""部门详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='部门 ID')
del_flag: bool del_flag: bool = Field(description='是否删除')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.dict_type import GetDictTypeDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DictDataSchemaBase(SchemaBase):
type_id: int
label: str
value: str
sort: int
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None
class CreateDictDataParam(DictDataSchemaBase):
pass
class UpdateDictDataParam(DictDataSchemaBase):
pass
class GetDictDataDetail(DictDataSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
type: GetDictTypeDetail | None = None
created_time: datetime
updated_time: datetime | None = None
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DictTypeSchemaBase(SchemaBase):
name: str
code: str
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None
class CreateDictTypeParam(DictTypeSchemaBase):
pass
class UpdateDictTypeParam(DictTypeSchemaBase):
pass
class GetDictTypeDetail(DictTypeSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
+22 -18
View File
@@ -2,37 +2,41 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict from pydantic import ConfigDict, Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class LoginLogSchemaBase(SchemaBase): class LoginLogSchemaBase(SchemaBase):
user_uuid: str """登录日志基础模型"""
username: str
status: int user_uuid: str = Field(description='用户 UUID')
ip: str username: str = Field(description='用户名')
country: str | None status: int = Field(description='登录状态')
region: str | None ip: str = Field(description='IP 地址')
city: str | None country: str | None = Field(None, description='国家')
user_agent: str region: str | None = Field(None, description='地区')
browser: str | None city: str | None = Field(None, description='城市')
os: str | None user_agent: str = Field(description='用户代理')
device: str | None browser: str | None = Field(None, description='浏览器')
msg: str os: str | None = Field(None, description='操作系统')
login_time: datetime device: str | None = Field(None, description='设备')
msg: str = Field(description='消息')
login_time: datetime = Field(description='登录时间')
class CreateLoginLogParam(LoginLogSchemaBase): class CreateLoginLogParam(LoginLogSchemaBase):
pass """创建登录日志参数"""
class UpdateLoginLogParam(LoginLogSchemaBase): class UpdateLoginLogParam(LoginLogSchemaBase):
pass """更新登录日志参数"""
class GetLoginLogDetail(LoginLogSchemaBase): class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='日志 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
+23 -18
View File
@@ -9,32 +9,37 @@ from backend.common.schema import SchemaBase
class MenuSchemaBase(SchemaBase): class MenuSchemaBase(SchemaBase):
title: str """菜单基础模型"""
name: str
parent_id: int | None = Field(default=None, description='菜单父级ID') title: str = Field(description='菜单标题')
sort: int = Field(default=0, ge=0, description='排序') name: str = Field(description='菜单名称')
icon: str | None = None path: str = Field(description='路由路径')
path: str | None = None parent_id: int | None = Field(None, description='菜单父级 ID')
menu_type: MenuType = Field(default=MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)') sort: int = Field(0, ge=0, description='排序')
component: str | None = None icon: str | None = Field(None, description='图标')
perms: str | None = None type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮)')
status: StatusType = Field(default=StatusType.enable) component: str | None = Field(None, description='组件路径')
display: StatusType = Field(default=StatusType.enable) perms: str | None = Field(None, description='权限标识')
cache: StatusType = Field(default=StatusType.enable) status: StatusType = Field(StatusType.enable, description='状态')
remark: str | None = None display: StatusType = Field(StatusType.enable, description='是否显示')
cache: StatusType = Field(StatusType.enable, description='是否缓存')
link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注')
class CreateMenuParam(MenuSchemaBase): class CreateMenuParam(MenuSchemaBase):
pass """创建菜单参数"""
class UpdateMenuParam(MenuSchemaBase): class UpdateMenuParam(MenuSchemaBase):
pass """更新菜单参数"""
class GetMenuDetail(MenuSchemaBase): class GetMenuDetail(MenuSchemaBase):
"""菜单详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='菜单 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
+28 -23
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -9,37 +10,41 @@ from backend.common.schema import SchemaBase
class OperaLogSchemaBase(SchemaBase): class OperaLogSchemaBase(SchemaBase):
trace_id: str """操作日志基础模型"""
username: str | None = None
method: str trace_id: str = Field(description='追踪 ID')
title: str username: str | None = Field(None, description='用户名')
path: str method: str = Field(description='请求方法')
ip: str title: str = Field(description='操作标题')
country: str | None = None path: str = Field(description='请求路径')
region: str | None = None ip: str = Field(description='IP 地址')
city: str | None = None country: str | None = Field(None, description='国家')
user_agent: str region: str | None = Field(None, description='地区')
os: str | None = None city: str | None = Field(None, description='城市')
browser: str | None = None user_agent: str = Field(description='用户代理')
device: str | None = None os: str | None = Field(None, description='操作系统')
args: dict | None = None browser: str | None = Field(None, description='浏览器')
status: StatusType = Field(default=StatusType.enable) device: str | None = Field(None, description='设备')
code: str args: dict[str, Any] | None = Field(None, description='请求参数')
msg: str | None = None status: StatusType = Field(StatusType.enable, description='状态')
cost_time: float code: str = Field(description='状态码')
opera_time: datetime msg: str | None = Field(None, description='消息')
cost_time: float = Field(description='耗时')
opera_time: datetime = Field(description='操作时间')
class CreateOperaLogParam(OperaLogSchemaBase): class CreateOperaLogParam(OperaLogSchemaBase):
pass """创建操作日志参数"""
class UpdateOperaLogParam(OperaLogSchemaBase): class UpdateOperaLogParam(OperaLogSchemaBase):
pass """更新操作日志参数"""
class GetOperaLogDetail(OperaLogSchemaBase): class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='日志 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
+25 -12
View File
@@ -11,32 +11,45 @@ from backend.common.schema import SchemaBase
class RoleSchemaBase(SchemaBase): class RoleSchemaBase(SchemaBase):
name: str """角色基础模型"""
status: StatusType = Field(default=StatusType.enable)
remark: str | None = None name: str = Field(description='角色名称')
status: StatusType = Field(StatusType.enable, description='状态')
remark: str | None = Field(None, description='备注')
class CreateRoleParam(RoleSchemaBase): class CreateRoleParam(RoleSchemaBase):
pass """创建角色参数"""
class UpdateRoleParam(RoleSchemaBase): class UpdateRoleParam(RoleSchemaBase):
pass """更新角色参数"""
class UpdateRoleMenuParam(SchemaBase): class UpdateRoleMenuParam(SchemaBase):
menus: list[int] """更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表')
class UpdateRoleRuleParam(SchemaBase): class UpdateRoleRuleParam(SchemaBase):
rules: list[int] """更新角色规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表')
class GetRoleDetail(RoleSchemaBase): class GetRoleDetail(RoleSchemaBase):
"""角色详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='角色 ID')
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
menus: list[GetMenuDetail | None] = []
rules: list[GetDataRuleDetail | None] = []
class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
rules: list[GetDataRuleDetail | None] = Field([], description='数据规则详情列表')
+33 -21
View File
@@ -2,44 +2,56 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from backend.app.admin.schema.user import GetUserInfoNoRelationDetail from pydantic import Field
from backend.app.admin.schema.user import GetUserInfoDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class GetSwaggerToken(SchemaBase): class GetSwaggerToken(SchemaBase):
access_token: str """Swagger 认证令牌"""
token_type: str = 'Bearer'
user: GetUserInfoNoRelationDetail access_token: str = Field(description='访问令牌')
token_type: str = Field('Bearer', description='令牌类型')
user: GetUserInfoDetail = Field(description='用户信息')
class AccessTokenBase(SchemaBase): class AccessTokenBase(SchemaBase):
access_token: str """访问令牌基础模型"""
access_token_expire_time: datetime
session_uuid: str access_token: str = Field(description='访问令牌')
access_token_expire_time: datetime = Field(description='令牌过期时间')
session_uuid: str = Field(description='会话 UUID')
class GetNewToken(AccessTokenBase): class GetNewToken(AccessTokenBase):
pass """获取新令牌"""
class GetLoginToken(AccessTokenBase): class GetLoginToken(AccessTokenBase):
user: GetUserInfoNoRelationDetail """获取登录令牌"""
user: GetUserInfoDetail = Field(description='用户信息')
class KickOutToken(SchemaBase): class KickOutToken(SchemaBase):
session_uuid: str """踢出令牌"""
session_uuid: str = Field(description='会话 UUID')
class GetTokenDetail(SchemaBase): class GetTokenDetail(SchemaBase):
id: int """令牌详情"""
session_uuid: str
username: str id: int = Field(description='用户 ID')
nickname: str session_uuid: str = Field(description='会话 UUID')
ip: str username: str = Field(description='用户名')
os: str nickname: str = Field(description='昵称')
browser: str ip: str = Field(description='IP 地址')
device: str os: str = Field(description='操作系统')
status: StatusType browser: str = Field(description='浏览器')
last_login_time: str device: str = Field(description='设备')
expire_time: datetime status: StatusType = Field(description='状态')
last_login_time: str = Field(description='最后登录时间')
expire_time: datetime = Field(description='过期时间')
+63 -45
View File
@@ -7,84 +7,112 @@ from pydantic import ConfigDict, EmailStr, Field, HttpUrl, model_validator
from typing_extensions import Self 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 GetRoleDetail 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 CustomPhoneNumber, SchemaBase
class AuthSchemaBase(SchemaBase): class AuthSchemaBase(SchemaBase):
username: str """用户认证基础模型"""
password: str | None
username: str = Field(description='用户名')
password: str | None = Field(description='密码')
class AuthLoginParam(AuthSchemaBase): class AuthLoginParam(AuthSchemaBase):
captcha: str """用户登录参数"""
captcha: str = Field(description='验证码')
class RegisterUserParam(AuthSchemaBase): class RegisterUserParam(AuthSchemaBase):
nickname: str | None = None """用户注册参数"""
email: EmailStr = Field(examples=['user@example.com'])
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class AddUserParam(AuthSchemaBase): class AddUserParam(AuthSchemaBase):
dept_id: int """添加用户参数"""
roles: list[int]
nickname: str | None = None dept_id: int = Field(description='部门 ID')
email: EmailStr = Field(examples=['user@example.com']) roles: list[int] = Field(description='角色 ID 列表')
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class ResetPasswordParam(SchemaBase):
"""重置密码参数"""
old_password: str = Field(description='旧密码')
new_password: str = Field(description='新密码')
confirm_password: str = Field(description='确认密码')
class UserInfoSchemaBase(SchemaBase): class UserInfoSchemaBase(SchemaBase):
dept_id: int | None = None """用户信息基础模型"""
username: str
nickname: str dept_id: int | None = Field(None, description='部门 ID')
email: EmailStr = Field(examples=['user@example.com']) username: str = Field(description='用户名')
phone: CustomPhoneNumber | None = None nickname: str = Field(description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
class UpdateUserParam(UserInfoSchemaBase): class UpdateUserParam(UserInfoSchemaBase):
pass """更新用户参数"""
class UpdateUserRoleParam(SchemaBase): class UpdateUserRoleParam(SchemaBase):
roles: list[int] """更新用户角色参数"""
roles: list[int] = Field(description='角色 ID 列表')
class AvatarParam(SchemaBase): class AvatarParam(SchemaBase):
"""更新头像参数"""
url: HttpUrl = Field(description='头像 http 地址') url: HttpUrl = Field(description='头像 http 地址')
class GetUserInfoNoRelationDetail(UserInfoSchemaBase): class GetUserInfoDetail(UserInfoSchemaBase):
"""用户信息详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept_id: int | None = None dept_id: int | None = Field(None, description='部门 ID')
id: int id: int = Field(description='用户 ID')
uuid: str uuid: str = Field(description='用户 UUID')
avatar: str | None = None avatar: str | None = Field(None, description='头像')
status: StatusType = Field(default=StatusType.enable) status: StatusType = Field(StatusType.enable, description='状态')
is_superuser: bool is_superuser: bool = Field(description='是否超级管理员')
is_staff: bool is_staff: bool = Field(description='是否管理员')
is_multi_login: bool is_multi_login: bool = Field(description='是否允许多端登录')
join_time: datetime = None join_time: datetime = Field(description='加入时间')
last_login_time: datetime | None = None last_login_time: datetime | None = Field(None, description='最后登录时间')
class GetUserInfoDetail(GetUserInfoNoRelationDetail): class GetUserInfoWithRelationDetail(GetUserInfoDetail):
"""用户信息关联详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept: GetDeptDetail | None = None dept: GetDeptDetail | None = Field(None, description='部门信息')
roles: list[GetRoleDetail] roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
class GetCurrentUserInfoDetail(GetUserInfoDetail): class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
"""当前用户信息关联详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
dept: str | None = None dept: str | None = Field(None, description='部门名称')
roles: list[str] roles: list[str] = Field(description='角色名称列表')
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def handel(cls, data: Any) -> Self: def handel(cls, data: Any) -> Self:
"""处理部门和角色""" """处理部门和角色数据"""
dept = data['dept'] dept = data['dept']
if dept: if dept:
data['dept'] = dept['name'] data['dept'] = dept['name']
@@ -92,13 +120,3 @@ class GetCurrentUserInfoDetail(GetUserInfoDetail):
if roles: if roles:
data['roles'] = [role['name'] for role in roles] data['roles'] = [role['name'] for role in roles]
return data return data
class CurrentUserIns(GetUserInfoDetail):
model_config = ConfigDict(from_attributes=True)
class ResetPasswordParam(SchemaBase):
old_password: str
new_password: str
confirm_password: str
+14 -8
View File
@@ -1,21 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from pydantic import Field
from backend.common.enums import UserSocialType from backend.common.enums import UserSocialType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class UserSocialSchemaBase(SchemaBase): class UserSocialSchemaBase(SchemaBase):
source: UserSocialType """用户社交基础模型"""
open_id: str | None = None
uid: str | None = None source: UserSocialType = Field(description='社交平台')
union_id: str | None = None open_id: str | None = Field(None, description='开放平台 ID')
scope: str | None = None uid: str | None = Field(None, description='用户 ID')
code: str | None = None union_id: str | None = Field(None, description='开放平台唯一 ID')
scope: str | None = Field(None, description='授权范围')
code: str | None = Field(None, description='授权码')
class CreateUserSocialParam(UserSocialSchemaBase): class CreateUserSocialParam(UserSocialSchemaBase):
user_id: int """创建用户社交参数"""
user_id: int = Field(description='用户 ID')
class UpdateUserSocialParam(SchemaBase): class UpdateUserSocialParam(SchemaBase):
pass """更新用户社交参数"""
+48 -4
View File
@@ -30,18 +30,40 @@ from backend.utils.timezone import timezone
class AuthService: class AuthService:
"""认证服务类"""
@staticmethod @staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> User: async def user_verify(db: AsyncSession, username: str, password: str | None) -> User:
"""
验证用户名和密码
:param db: 数据库会话
:param username: 用户名
:param password: 密码
:return:
"""
user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not user: if not user:
raise errors.NotFoundError(msg='用户名或密码有误') raise errors.NotFoundError(msg='用户名或密码有误')
elif not password_verify(password, user.password):
if user.password is None:
raise errors.AuthorizationError(msg='用户名或密码有误') raise errors.AuthorizationError(msg='用户名或密码有误')
elif not user.status: else:
if not password_verify(password, user.password):
raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
return user return user
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]: async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
"""
Swagger 文档登录
:param obj: 登录凭证
:return:
"""
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)
@@ -49,13 +71,22 @@ class AuthService:
str(user.id), str(user.id),
user.is_multi_login, user.is_multi_login,
# extra info # extra info
login_type='swagger', swagger=True,
) )
return a_token.access_token, user return a_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
) -> GetLoginToken: ) -> GetLoginToken:
"""
用户登录
:param request: 请求对象
:param response: 响应对象
:param obj: 登录参数
:param background_tasks: 后台任务
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = None user = None
try: try:
@@ -133,6 +164,12 @@ class AuthService:
@staticmethod @staticmethod
async def new_token(*, request: Request) -> GetNewToken: async def new_token(*, request: Request) -> GetNewToken:
"""
获取新的访问令牌
:param request: FastAPI 请求对象
:return:
"""
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 已过期,请重新登录')
@@ -168,6 +205,13 @@ class AuthService:
@staticmethod @staticmethod
async def logout(*, request: Request, response: Response) -> None: async def logout(*, request: Request, response: Response) -> None:
"""
用户登出
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:return:
"""
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
+61 -10
View File
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from fastapi import Request
from sqlalchemy import Select 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
@@ -17,8 +16,16 @@ from backend.utils.import_parse import dynamic_import_data_model
class DataRuleService: class DataRuleService:
"""数据规则服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> DataRule: async def get(*, pk: int) -> DataRule:
"""
获取数据规则详情
:param pk: 规则 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
data_rule = await data_rule_dao.get(db, pk) data_rule = await data_rule_dao.get(db, pk)
if not data_rule: if not data_rule:
@@ -27,6 +34,12 @@ class DataRuleService:
@staticmethod @staticmethod
async def get_role_rules(*, pk: int) -> list[int]: async def get_role_rules(*, pk: int) -> list[int]:
"""
获取角色的数据规则列表
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_with_relation(db, pk)
if not role: if not role:
@@ -36,53 +49,91 @@ class DataRuleService:
@staticmethod @staticmethod
async def get_models() -> list[str]: async def get_models() -> list[str]:
"""获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys()) return list(settings.DATA_PERMISSION_MODELS.keys())
@staticmethod @staticmethod
async def get_columns(model: str) -> list[str]: async def get_columns(model: str) -> list[str]:
"""
获取数据规则可用模型的字段列表
:param model: 模型名称
:return:
"""
if model not in settings.DATA_PERMISSION_MODELS: if model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据模型不存在') raise errors.NotFoundError(msg='数据规则可用模型不存在')
try:
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
except (ImportError, AttributeError):
raise errors.ServerError(msg=f'数据模型 {model} 动态导入失败,请联系系统超级管理员')
model_columns = [ model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
return model_columns return model_columns
@staticmethod @staticmethod
async def get_select(*, name: str = None) -> Select: async def get_select(*, name: str | None) -> Select:
"""
获取数据规则列表查询条件
:param name: 规则名称
:return:
"""
return await data_rule_dao.get_list(name=name) return await data_rule_dao.get_list(name=name)
@staticmethod @staticmethod
async def get_all() -> Sequence[DataRule]: async def get_all() -> Sequence[DataRule]:
"""获取所有数据规则"""
async with async_db_session() as db: async with async_db_session() as db:
data_rules = await data_rule_dao.get_all(db) data_rules = await data_rule_dao.get_all(db)
return data_rules return data_rules
@staticmethod @staticmethod
async def create(*, obj: CreateDataRuleParam) -> None: async def create(*, obj: CreateDataRuleParam) -> None:
"""
创建数据规则
:param obj: 规则创建参数
:return:
"""
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.ForbiddenError(msg='数据规则已存在')
await data_rule_dao.create(db, obj) await data_rule_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: async def update(*, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新数据规则
:param pk: 规则 ID
:param obj: 规则更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
data_rule = await data_rule_dao.get(db, pk) data_rule = await data_rule_dao.get(db, pk)
if not data_rule: if not data_rule:
raise errors.NotFoundError(msg='数据权限规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
count = await data_rule_dao.update(db, pk, obj) count = await data_rule_dao.update(db, pk, obj)
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def delete(*, request: Request, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除数据规则
:param pk: 规则 ID 列表
: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, pk)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') for _pk in pk:
data_rule = await data_rule_dao.get(db, _pk)
if data_rule:
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+42 -7
View File
@@ -2,8 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request
from backend.app.admin.crud.crud_dept import dept_dao from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
@@ -15,8 +13,16 @@ from backend.utils.build_tree import get_tree_data
class DeptService: class DeptService:
"""部门服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Dept: async def get(*, pk: int) -> Dept:
"""
获取部门详情
:param pk: 部门 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
@@ -25,8 +31,17 @@ class DeptService:
@staticmethod @staticmethod
async def get_dept_tree( async def get_dept_tree(
*, name: str | None = None, leader: str | None = None, phone: str | None = None, status: int | None = None *, name: str | None, leader: str | None, phone: str | None, status: int | None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""
获取部门树形结构
:param name: 部门名称
:param leader: 部门负责人
:param phone: 联系电话
:param status: 状态
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
dept_select = await dept_dao.get_all(db=db, name=name, leader=leader, phone=phone, status=status) dept_select = await dept_dao.get_all(db=db, name=name, leader=leader, phone=phone, status=status)
tree_data = get_tree_data(dept_select) tree_data = get_tree_data(dept_select)
@@ -34,6 +49,12 @@ class DeptService:
@staticmethod @staticmethod
async def create(*, obj: CreateDeptParam) -> None: async def create(*, obj: CreateDeptParam) -> None:
"""
创建部门
:param obj: 部门创建参数
:return:
"""
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:
@@ -46,6 +67,13 @@ class DeptService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDeptParam) -> int: async def update(*, pk: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param pk: 部门 ID
:param obj: 部门更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
@@ -63,16 +91,23 @@ class DeptService:
return count return count
@staticmethod @staticmethod
async def delete(*, request: Request, pk: int) -> int: async def delete(*, pk: int) -> int:
"""
删除部门
:param pk: 部门 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
dept_user = await dept_dao.get_with_relation(db, pk) dept = await dept_dao.get_with_relation(db, pk)
if dept_user: if dept.users:
raise errors.ForbiddenError(msg='部门下存在用户,无法删除') raise errors.ForbiddenError(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.ForbiddenError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk) count = await dept_dao.delete(db, pk)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') for user in dept.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+32 -3
View File
@@ -13,8 +13,18 @@ from backend.database.db import async_db_session
class LoginLogService: class LoginLogService:
"""登录日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str, status: int, ip: str) -> Select: async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取登录日志列表查询条件
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
return await login_log_dao.get_list(username=username, status=status, ip=ip) return await login_log_dao.get_list(username=username, status=status, ip=ip)
@staticmethod @staticmethod
@@ -28,8 +38,20 @@ class LoginLogService:
status: int, status: int,
msg: str, msg: str,
) -> None: ) -> None:
"""
创建登录日志
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_uuid: 用户 UUID
:param username: 用户名
:param login_time: 登录时间
:param status: 状态
:param msg: 消息
:return:
"""
try: try:
obj_in = CreateLoginLogParam( obj = CreateLoginLogParam(
user_uuid=user_uuid, user_uuid=user_uuid,
username=username, username=username,
status=status, status=status,
@@ -44,18 +66,25 @@ class LoginLogService:
msg=msg, msg=msg,
login_time=login_time, login_time=login_time,
) )
await login_log_dao.create(db, obj_in) await login_log_dao.create(db, obj)
except Exception as e: except Exception as e:
log.error(f'登录日志创建失败: {e}') log.error(f'登录日志创建失败: {e}')
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除登录日志
:param pk: 日志 ID 列表
: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, pk)
return count return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete_all() -> int:
"""清空所有登录日志"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await login_log_dao.delete_all(db) count = await login_log_dao.delete_all(db)
return count return count
+58 -5
View File
@@ -12,12 +12,20 @@ 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
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
class MenuService: class MenuService:
"""菜单服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Menu: async def get(*, pk: int) -> Menu:
"""
获取菜单详情
:param pk: 菜单 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
menu = await menu_dao.get(db, menu_id=pk) menu = await menu_dao.get(db, menu_id=pk)
if not menu: if not menu:
@@ -25,7 +33,14 @@ class MenuService:
return menu return menu
@staticmethod @staticmethod
async def get_menu_tree(*, title: str | None = None, status: int | None = None) -> list[dict[str, Any]]: async def get_menu_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]:
"""
获取菜单树形结构
:param title: 菜单标题
:param status: 状态
: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_select = await menu_dao.get_all(db, title=title, status=status)
menu_tree = get_tree_data(menu_select) menu_tree = get_tree_data(menu_select)
@@ -33,6 +48,12 @@ class MenuService:
@staticmethod @staticmethod
async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]: async def get_role_menu_tree(*, pk: int) -> list[dict[str, Any]]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_with_relation(db, pk)
if not role: if not role:
@@ -44,6 +65,12 @@ class MenuService:
@staticmethod @staticmethod
async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]: async def get_user_menu_tree(*, request: Request) -> list[dict[str, Any]]:
"""
获取用户的菜单树形结构
:param request: FastAPI 请求对象
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
roles = request.user.roles roles = request.user.roles
menu_ids = [] menu_ids = []
@@ -52,11 +79,17 @@ class MenuService:
for role in roles: for role in roles:
menu_ids.extend([menu.id for menu in role.menus]) menu_ids.extend([menu.id for menu in role.menus])
menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids) menu_select = await menu_dao.get_role_menus(db, request.user.is_superuser, menu_ids)
menu_tree = get_tree_data(menu_select) menu_tree = get_vben5_tree_data(menu_select)
return menu_tree return menu_tree
@staticmethod @staticmethod
async def create(*, obj: CreateMenuParam) -> None: async def create(*, obj: CreateMenuParam) -> None:
"""
创建菜单
:param obj: 菜单创建参数
:return:
"""
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:
@@ -69,6 +102,13 @@ class MenuService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateMenuParam) -> int: async def update(*, pk: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param pk: 菜单 ID
:param obj: 菜单更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
menu = await menu_dao.get(db, pk) menu = await menu_dao.get(db, pk)
if not menu: if not menu:
@@ -83,16 +123,29 @@ class MenuService:
if obj.parent_id == menu.id: if obj.parent_id == menu.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级') raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await menu_dao.update(db, pk, obj) count = await menu_dao.update(db, pk, obj)
for role in await menu.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def delete(*, request: Request, pk: int) -> int: async def delete(*, pk: int) -> int:
"""
删除菜单
:param pk: 菜单 ID
:return:
"""
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.ForbiddenError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk) count = await menu_dao.delete(db, pk)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') if menu:
for role in await menu.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+16 -2
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any
from fast_captcha import text_captcha from fast_captcha import text_captcha
from fastapi import BackgroundTasks, Request, Response from fastapi import BackgroundTasks, Request, Response
@@ -20,15 +22,27 @@ from backend.utils.timezone import timezone
class OAuth2Service: class OAuth2Service:
"""OAuth2 认证服务类"""
@staticmethod @staticmethod
async def create_with_login( async def create_with_login(
*, *,
request: Request, request: Request,
response: Response, response: Response,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user: dict, user: dict[str, Any],
social: UserSocialType, social: UserSocialType,
) -> GetLoginToken | None: ) -> GetLoginToken | None:
"""
创建 OAuth2 用户并登录
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:param background_tasks: FastAPI 后台任务
:param user: OAuth2 用户信息
:param social: 社交平台类型
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
# 获取 OAuth2 平台用户信息 # 获取 OAuth2 平台用户信息
social_id = user.get('id') social_id = user.get('id')
@@ -37,7 +51,7 @@ class OAuth2Service:
social_username = user.get('login') social_username = user.get('login')
social_nickname = user.get('name') social_nickname = user.get('name')
social_email = user.get('email') social_email = user.get('email')
if social == UserSocialType.linuxdo: # 不提供明文邮箱的平台 if social == UserSocialType.linux_do: # 不提供明文邮箱的平台
social_email = f'{social_username}@linux.do' social_email = f'{social_username}@linux.do'
if not social_email: if not social_email:
raise AuthorizationError(msg=f'授权失败,{social.value} 账户未绑定邮箱') raise AuthorizationError(msg=f'授权失败,{social.value} 账户未绑定邮箱')
+26 -3
View File
@@ -8,23 +8,46 @@ from backend.database.db import async_db_session
class OperaLogService: class OperaLogService:
"""操作日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str | None = None, status: int | None = None, ip: str | None = None) -> Select: async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取操作日志列表查询条件
:param username: 用户名
:param status: 状态
:param ip: IP 地址
:return:
"""
return await opera_log_dao.get_list(username=username, status=status, ip=ip) return await opera_log_dao.get_list(username=username, status=status, ip=ip)
@staticmethod @staticmethod
async def create(*, obj_in: CreateOperaLogParam): async def create(*, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param obj: 操作日志创建参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
await opera_log_dao.create(db, obj_in) await opera_log_dao.create(db, obj)
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除操作日志
:param pk: 日志 ID 列表
: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, pk)
return count return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete_all() -> int:
"""清空所有操作日志"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
count = await opera_log_dao.delete_all(db) count = await opera_log_dao.delete_all(db)
return count return count
+72 -12
View File
@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Sequence from typing import Sequence
from fastapi import Request
from sqlalchemy import Select 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
@@ -22,8 +21,16 @@ from backend.database.redis import redis_client
class RoleService: class RoleService:
"""角色服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Role: async def get(*, pk: int) -> Role:
"""
获取角色详情
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_with_relation(db, pk)
if not role: if not role:
@@ -32,22 +39,42 @@ class RoleService:
@staticmethod @staticmethod
async def get_all() -> Sequence[Role]: async def get_all() -> Sequence[Role]:
"""获取所有角色"""
async with async_db_session() as db: async with async_db_session() as db:
roles = await role_dao.get_all(db) roles = await role_dao.get_all(db)
return roles return roles
@staticmethod @staticmethod
async def get_by_user(*, pk: int) -> Sequence[Role]: async def get_by_user(*, pk: int) -> Sequence[Role]:
"""
获取用户的角色列表
:param pk: 用户 ID
:return:
"""
async with async_db_session() as db: async with async_db_session() as db:
roles = await role_dao.get_by_user(db, user_id=pk) roles = await role_dao.get_by_user(db, user_id=pk)
return roles 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:
"""
获取角色列表查询条件
:param name: 角色名称
:param status: 状态
:return:
"""
return await role_dao.get_list(name=name, status=status) return await role_dao.get_list(name=name, status=status)
@staticmethod @staticmethod
async def create(*, obj: CreateRoleParam) -> None: async def create(*, obj: CreateRoleParam) -> None:
"""
创建角色
:param obj: 角色创建参数
:return:
"""
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:
@@ -56,6 +83,13 @@ class RoleService:
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateRoleParam) -> int: async def update(*, pk: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param pk: 角色 ID
:param obj: 角色更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
@@ -65,12 +99,21 @@ class RoleService:
if role: if role:
raise errors.ForbiddenError(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:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_role_menu(*, request: Request, pk: int, menu_ids: UpdateRoleMenuParam) -> int: async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
"""
更新角色菜单
:param pk: 角色 ID
:param menu_ids: 菜单 ID 列表
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get(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='角色不存在')
for menu_id in menu_ids.menus: for menu_id in menu_ids.menus:
@@ -78,12 +121,19 @@ class RoleService:
if not menu: if not menu:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
count = await role_dao.update_menus(db, pk, menu_ids) count = await role_dao.update_menus(db, pk, menu_ids)
if pk in [role.id for role in request.user.roles]: for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_role_rule(*, request: Request, pk: int, rule_ids: UpdateRoleRuleParam) -> int: async def update_role_rule(*, pk: int, rule_ids: UpdateRoleRuleParam) -> int:
"""
更新角色数据规则
:param pk: 角色 ID
:param rule_ids: 权限规则 ID 列表
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
@@ -91,17 +141,27 @@ class RoleService:
for rule_id in rule_ids.rules: for rule_id in rule_ids.rules:
rule = await data_rule_dao.get(db, rule_id) rule = await data_rule_dao.get(db, rule_id)
if not rule: if not rule:
raise errors.NotFoundError(msg='数据权限不存在') raise errors.NotFoundError(msg='数据规则不存在')
count = await role_dao.update_rules(db, pk, rule_ids) count = await role_dao.update_rules(db, pk, rule_ids)
if pk in [role.id for role in request.user.roles]: for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def delete(*, request: Request, pk: list[int]) -> int: async def delete(*, pk: list[int]) -> int:
"""
删除角色
:param pk: 角色 ID 列表
: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, pk)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') for _pk in pk:
role = await role_dao.get(db, _pk)
if role:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+144 -63
View File
@@ -25,8 +25,16 @@ from backend.database.redis import redis_client
class UserService: class UserService:
"""用户服务类"""
@staticmethod @staticmethod
async def register(*, obj: RegisterUserParam) -> None: async def register(*, obj: RegisterUserParam) -> None:
"""
注册新用户
:param obj: 用户注册参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not obj.password: if not obj.password:
raise errors.ForbiddenError(msg='密码为空') raise errors.ForbiddenError(msg='密码为空')
@@ -44,6 +52,13 @@ class UserService:
@staticmethod @staticmethod
async def add(*, request: Request, obj: AddUserParam) -> None: async def add(*, request: Request, obj: AddUserParam) -> None:
"""
添加新用户
:param request: FastAPI 请求对象
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
username = await user_dao.get_by_username(db, obj.username) username = await user_dao.get_by_username(db, obj.username)
@@ -69,13 +84,20 @@ class UserService:
@staticmethod @staticmethod
async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int: async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int:
"""
重置用户密码
:param request: FastAPI 请求对象
:param obj: 密码重置参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
user = await user_dao.get(db, request.user.id) 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): if not password_verify(obj.old_password, user.password):
raise errors.ForbiddenError(msg='原密码错误') raise errors.ForbiddenError(msg='原密码错误')
np1 = obj.new_password if obj.new_password != obj.confirm_password:
np2 = obj.confirm_password
if np1 != np2:
raise errors.ForbiddenError(msg='密码输入不一致') raise errors.ForbiddenError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt) new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, request.user.id, new_pwd) count = await user_dao.reset_password(db, request.user.id, new_pwd)
@@ -90,6 +112,12 @@ class UserService:
@staticmethod @staticmethod
async def get_userinfo(*, username: str) -> User: async def get_userinfo(*, username: str) -> User:
"""
获取用户信息
:param username: 用户名
: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, username=username)
if not user: if not user:
@@ -98,35 +126,49 @@ class UserService:
@staticmethod @staticmethod
async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int: async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param request: FastAPI 请求对象
:param username: 用户名
:param obj: 用户更新参数
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
if not request.user.is_superuser:
if request.user.username != username: if request.user.username != username:
raise errors.ForbiddenError(msg='你只能修改自己的信息') raise errors.ForbiddenError(msg='你只能修改自己的信息')
input_user = await user_dao.get_with_relation(db, username=username) user = await user_dao.get_with_relation(db, username=username)
if not input_user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if input_user.username != obj.username: if user.username != obj.username:
_username = await user_dao.get_by_username(db, obj.username) _username = await user_dao.get_by_username(db, obj.username)
if _username: if _username:
raise errors.ForbiddenError(msg='用户名已注册') raise errors.ForbiddenError(msg='用户名已注册')
if input_user.nickname != obj.nickname: if user.nickname != obj.nickname:
nickname = await user_dao.get_by_nickname(db, obj.nickname) nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname: if nickname:
raise errors.ForbiddenError(msg='昵称已注册') raise errors.ForbiddenError(msg='昵称已注册')
if input_user.email != obj.email: if user.email != obj.email:
email = await user_dao.check_email(db, obj.email) email = await user_dao.check_email(db, obj.email)
if email: if email:
raise errors.ForbiddenError(msg='邮箱已注册') raise errors.ForbiddenError(msg='邮箱已注册')
count = await user_dao.update_userinfo(db, input_user.id, obj) count = await user_dao.update_userinfo(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_roles(*, request: Request, username: str, obj: UpdateUserRoleParam) -> None: 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: async with async_db_session.begin() as db:
if not request.user.is_superuser: if not request.user.is_superuser and request.user.username != username:
if request.user.username != username: raise errors.ForbiddenError(msg='你只能修改自己的信息')
raise errors.AuthorizationError
input_user = await user_dao.get_with_relation(db, username=username) input_user = await user_dao.get_with_relation(db, username=username)
if not input_user: if not input_user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
@@ -139,109 +181,148 @@ class UserService:
@staticmethod @staticmethod
async def update_avatar(*, request: Request, username: str, avatar: AvatarParam) -> int: 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: async with async_db_session.begin() as db:
if not request.user.is_superuser:
if request.user.username != username: if request.user.username != username:
raise errors.AuthorizationError raise errors.AuthorizationError
input_user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not input_user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_avatar(db, input_user.id, avatar) count = await user_dao.update_avatar(db, user.id, avatar)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def get_select(*, dept: int, username: str = None, phone: str = None, status: int = None) -> Select: async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select:
"""
获取用户列表查询条件
:param dept: 部门 ID
:param username: 用户名
:param phone: 手机号
:param status: 状态
:return:
"""
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 update_permission(*, request: Request, pk: int) -> int:
"""
更新用户权限
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else:
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, False if super_status else True) count = await user_dao.set_super(db, pk, not super_status)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_staff(*, request: Request, pk: int) -> int: async def update_staff(*, request: Request, pk: int) -> int:
"""
更新用户职员状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else:
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, False if staff_status else True) count = await user_dao.set_staff(db, pk, not staff_status)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_status(*, request: Request, pk: int) -> int: async def update_status(*, request: Request, pk: int) -> int:
"""
更新用户状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else:
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, False if status else True) count = await user_dao.set_status(db, pk, 0 if status == 1 else 1)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{pk}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
async def update_multi_login(*, request: Request, pk: int) -> int: async def update_multi_login(*, request: Request, pk: int) -> int:
"""
更新用户多端登录状态
:param request: FastAPI 请求对象
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
superuser_verify(request) superuser_verify(request)
if not await user_dao.get(db, pk): user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
else: multi_login = await user_dao.get_multi_login(db, pk) if pk != user.id else request.user.is_multi_login
user_id = request.user.id new_multi_login = not multi_login
multi_login = await user_dao.get_multi_login(db, pk) if pk != user_id else request.user.is_multi_login count = await user_dao.set_multi_login(db, pk, new_multi_login)
count = await user_dao.set_multi_login(db, pk, False if multi_login else True) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}')
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
latest_multi_login = await user_dao.get_multi_login(db, pk) if pk == user.id:
# 超级用户修改自身时,除当前token外,其他token失效 # 系统管理员修改自身时,除当前 token 外,其他 token 失效
if pk == user_id: if not new_multi_login:
if not latest_multi_login: key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{pk}' await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}')
await redis_client.delete_prefix(
key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}'
)
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if refresh_token:
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{pk}'
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{refresh_token}')
# 超级用户修改他人时,其他token将全部失效
else: else:
if not latest_multi_login: # 系统管理员修改他人时,他人 token 全部失效
key_prefix = [f'{settings.TOKEN_REDIS_PREFIX}:{pk}'] if not new_multi_login:
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
if refresh_token: await redis_client.delete_prefix(key_prefix)
key_prefix.append(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{pk}')
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count return count
@staticmethod @staticmethod
async def delete(*, username: str) -> int: async def delete(*, username: str) -> int:
"""
删除用户
:param username: 用户名
:return:
"""
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
input_user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not input_user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.delete(db, input_user.id) count = await user_dao.delete(db, user.id)
key_prefix = [ key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{input_user.id}', f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{input_user.id}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
] ]
for key in key_prefix: for key in key_prefix:
await redis_client.delete_prefix(key) await redis_client.delete_prefix(key)
+4 -1
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio.session import AsyncSession
from backend.database.db import create_async_engine_and_session, create_database_url from backend.database.db import create_async_engine_and_session, create_database_url
@@ -8,7 +11,7 @@ TEST_SQLALCHEMY_DATABASE_URL = create_database_url(unittest=True)
_, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL) _, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL)
async def override_get_db(): async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
"""session 生成器""" """session 生成器"""
async with async_test_db_session() as session: async with async_test_db_session() as session:
yield session yield session
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.generator.api.v1.gen import router as gen_router
from backend.app.generator.api.v1.gen_business import router as gen_business_router
from backend.app.generator.api.v1.gen_model import router as gen_model_router
from backend.core.conf import settings
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/gen', tags=['代码生成'])
v1.include_router(gen_router)
v1.include_router(gen_business_router, prefix='/businesses')
v1.include_router(gen_model_router, prefix='/models')
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.generator.model import GenBusiness
from backend.app.generator.schema.gen_business import CreateGenBusinessParam, UpdateGenBusinessParam
class CRUDGenBusiness(CRUDPlus[GenBusiness]):
async def get(self, db: AsyncSession, pk: int) -> GenBusiness | None:
"""
获取代码生成业务表
:param db:
:param pk:
:return:
"""
return await self.select_model(db, pk)
async def get_by_name(self, db: AsyncSession, name: str) -> GenBusiness | None:
"""
通过 name 获取代码生成业务表
:param db:
:param name:
:return:
"""
return await self.select_model_by_column(db, table_name_en=name)
async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]:
"""
获取所有代码生成业务表
:return:
"""
return await self.select_models(db)
async def create(self, db: AsyncSession, obj_in: CreateGenBusinessParam) -> None:
"""
创建代码生成业务表
:param db:
:param obj_in:
:return:
"""
await self.create_model(db, obj_in)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateGenBusinessParam) -> int:
"""
更新代码生成业务表
:param db:
:param pk:
:param obj_in:
:return:
"""
return await self.update_model(db, pk, obj_in)
async def delete(self, db: AsyncSession, pk: int) -> int:
"""
删除代码生成业务表
:param db:
:param pk:
:return:
"""
return await self.delete_model(db, pk)
gen_business_dao: CRUDGenBusiness = CRUDGenBusiness(GenBusiness)
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.generator.model import GenModel
from backend.app.generator.schema.gen_model import CreateGenModelParam, UpdateGenModelParam
class CRUDGenModel(CRUDPlus[GenModel]):
async def get(self, db: AsyncSession, pk: int) -> GenModel | None:
"""
获取代码生成模型列
:return:
"""
return await self.select_model(db, pk)
async def get_all_by_business_id(self, db: AsyncSession, business_id: int) -> Sequence[GenModel]:
"""
获取所有代码生成模型列
:param db:
:param business_id:
:return:
"""
return await self.select_models_order(db, sort_columns='sort', gen_business_id=business_id)
async def create(self, db: AsyncSession, obj_in: CreateGenModelParam, pd_type: str | None = None) -> None:
"""
创建代码生成模型表
:param db:
:param obj_in:
:param pd_type:
:return:
"""
await self.create_model(db, obj_in, pd_type=pd_type)
async def update(self, db: AsyncSession, pk: int, obj_in: UpdateGenModelParam, pd_type: str | None = None) -> int:
"""
更细代码生成模型表
:param db:
:param pk:
:param obj_in:
:param pd_type:
:return:
"""
return await self.update_model(db, pk, obj_in, pd_type=pd_type)
async def delete(self, db: AsyncSession, pk: int) -> int:
"""
删除代码生成模型表
:param db:
:param pk:
:return:
"""
return await self.delete_model(db, pk)
gen_model_dao: CRUDGenModel = CRUDGenModel(GenModel)
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.app.generator.model.gen_business import GenBusiness
from backend.app.generator.model.gen_model import GenModel
@@ -1,45 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field, model_validator
from typing_extensions import Self
from backend.app.generator.schema.gen_model import GetGenModelDetail
from backend.common.schema import SchemaBase
class GenBusinessSchemaBase(SchemaBase):
app_name: str
table_name_en: str
table_name_zh: str
table_simple_name_zh: str
table_comment: str | None = None
schema_name: str | None = None
default_datetime_column: bool = Field(default=True)
api_version: str = Field(default='v1')
gen_path: str | None = None
remark: str | None = None
@model_validator(mode='after')
def check_schema_name(self) -> Self:
if self.schema_name is None:
self.schema_name = self.table_name_en
return self
class CreateGenBusinessParam(GenBusinessSchemaBase):
pass
class UpdateGenBusinessParam(GenBusinessSchemaBase):
pass
class GetGenBusinessDetail(GenBusinessSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_time: datetime
updated_time: datetime | None = None
gen_model: list[GetGenModelDetail] | None = None
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field, field_validator
from backend.common.schema import SchemaBase
from backend.utils.type_conversion import sql_type_to_sqlalchemy
class GenModelSchemaBase(SchemaBase):
name: str
comment: str | None = None
type: str
default: str | None = None
sort: int
length: int
is_pk: bool = Field(default=False)
is_nullable: bool = Field(default=False)
gen_business_id: int | None = Field(ge=1)
@field_validator('type')
@classmethod
def type_update(cls, v):
return sql_type_to_sqlalchemy(v)
class CreateGenModelParam(GenModelSchemaBase):
pass
class UpdateGenModelParam(GenModelSchemaBase):
pass
class GetGenModelDetail(GenModelSchemaBase):
model_config = ConfigDict(from_attributes=True)
id: int
pd_type: str
@@ -1,185 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import os.path
import zipfile
from pathlib import Path
from typing import Sequence
import aiofiles
from pydantic.alias_generators import to_pascal
from backend.app.generator.crud.crud_gen import gen_dao
from backend.app.generator.crud.crud_gen_business import gen_business_dao
from backend.app.generator.crud.crud_gen_model import gen_model_dao
from backend.app.generator.model import GenBusiness
from backend.app.generator.schema.gen import ImportParam
from backend.app.generator.schema.gen_business import CreateGenBusinessParam
from backend.app.generator.schema.gen_model import CreateGenModelParam
from backend.app.generator.service.gen_model_service import gen_model_service
from backend.common.exception import errors
from backend.core.path_conf import BasePath
from backend.database.db import async_db_session
from backend.utils.gen_template import gen_template
from backend.utils.type_conversion import sql_type_to_pydantic
class GenService:
@staticmethod
async def get_tables(*, table_schema: str) -> Sequence[str]:
async with async_db_session() as db:
return await gen_dao.get_all_tables(db, table_schema)
@staticmethod
async def import_business_and_model(*, obj: ImportParam) -> None:
async with async_db_session.begin() as db:
table_info = await gen_dao.get_table(db, obj.table_name)
if not table_info:
raise errors.NotFoundError(msg='数据库表不存在')
business_info = await gen_business_dao.get_by_name(db, obj.table_name)
if business_info:
raise errors.ForbiddenError(msg='已存在相同数据库表业务')
table_name = table_info[0]
business_data = {
'app_name': obj.app,
'table_name_en': table_name,
'table_name_zh': table_info[1] or ' '.join(table_name.split('_')),
'table_simple_name_zh': table_info[1] or table_name.split('_')[-1],
'table_comment': table_info[1],
}
new_business = GenBusiness(**CreateGenBusinessParam(**business_data).model_dump())
db.add(new_business)
await db.flush()
column_info = await gen_dao.get_all_columns(db, obj.table_schema, table_name)
for column in column_info:
column_type = column[-1].split('(')[0].upper()
pd_type = sql_type_to_pydantic(column_type)
model_data = {
'name': column[0],
'comment': column[-2],
'type': column_type,
'sort': column[-3],
'length': column[-1].split('(')[1][:-1] if pd_type == 'str' and '(' in column[-1] else 0,
'is_pk': column[1],
'is_nullable': column[2],
'gen_business_id': new_business.id,
}
await gen_model_dao.create(db, CreateGenModelParam(**model_data), pd_type=pd_type)
@staticmethod
async def render_tpl_code(*, business: GenBusiness) -> dict[str, str]:
gen_models = await gen_model_service.get_by_business(business_id=business.id)
if not gen_models:
raise errors.NotFoundError(msg='代码生成模型表为空')
gen_vars = gen_template.get_vars(business, gen_models)
tpl_code_map = {}
for tpl_path in gen_template.get_template_paths():
tpl_code_map[tpl_path] = await gen_template.get_template(tpl_path).render_async(**gen_vars)
return tpl_code_map
async def preview(self, *, pk: int) -> dict[str, bytes]:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
tpl_code_map = await self.render_tpl_code(business=business)
return {
tpl.replace('.jinja', '.py') if tpl.startswith('py') else ...: code.encode('utf-8')
for tpl, code in tpl_code_map.items()
}
@staticmethod
async def get_generate_path(*, pk: int) -> list[str]:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
gen_path = business.gen_path
if not gen_path:
# 伪加密路径
gen_path = 'current-backend-app-path'
target_files = gen_template.get_code_gen_paths(business)
code_gen_paths = []
for target_file in target_files:
code_gen_paths.append(os.path.join(gen_path, *target_file.split('/')[1:]))
return code_gen_paths
async def generate(self, *, pk: int) -> None:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
tpl_code_map = await self.render_tpl_code(business=business)
gen_path = business.gen_path
if not gen_path:
gen_path = os.path.join(BasePath, 'app')
for tpl_path, code in tpl_code_map.items():
code_filepath = os.path.join(
gen_path,
*gen_template.get_code_gen_path(tpl_path, business).split('/')[1:],
)
code_folder = Path(str(code_filepath)).parent
if not code_folder.exists():
code_folder.mkdir(parents=True, exist_ok=True)
# 写入 init 文件
init_filepath = code_folder.joinpath('__init__.py')
if not init_filepath.exists():
async with aiofiles.open(init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
if 'api' in str(code_folder):
# api __init__.py
api_init_filepath = code_folder.parent.joinpath('__init__.py')
if not api_init_filepath.exists():
async with aiofiles.open(api_init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
# app __init__.py
app_init_filepath = api_init_filepath.parent.joinpath('__init__.py')
if not app_init_filepath:
async with aiofiles.open(app_init_filepath, 'w', encoding='utf-8') as f:
await f.write(gen_template.init_content)
# 写入代码文件呢
async with aiofiles.open(code_filepath, 'w', encoding='utf-8') as f:
await f.write(code)
# model init 文件补充
if code_folder.name == 'model':
async with aiofiles.open(init_filepath, 'a', encoding='utf-8') as f:
await f.write(
f'from backend.app.{business.app_name}.model.{business.table_name_en} '
f'import {to_pascal(business.table_name_en)}\n',
)
async def download(self, *, pk: int) -> io.BytesIO:
async with async_db_session() as db:
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
bio = io.BytesIO()
zf = zipfile.ZipFile(bio, 'w')
tpl_code_map = await self.render_tpl_code(business=business)
for tpl_path, code in tpl_code_map.items():
# 写入代码文件
new_code_path = gen_template.get_code_gen_path(tpl_path, business)
zf.writestr(new_code_path, code)
# 写入 init 文件
init_filepath = os.path.join(*new_code_path.split('/')[:-1], '__init__.py')
if 'model' not in new_code_path.split('/'):
zf.writestr(init_filepath, gen_template.init_content)
else:
zf.writestr(
init_filepath,
f'{gen_template.init_content}'
f'from backend.app.{business.app_name}.model.{business.table_name_en} '
f'import {to_pascal(business.table_name_en)}\n',
)
if 'api' in new_code_path:
# api __init__.py
api_init_filepath = os.path.join(*new_code_path.split('/')[:-2], '__init__.py')
zf.writestr(api_init_filepath, gen_template.init_content)
zf.close()
bio.seek(0)
return bio
gen_service: GenService = GenService()
-2
View File
@@ -3,11 +3,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1 from backend.app.admin.api.router import v1 as admin_v1
from backend.app.generator.api.router import v1 as generator_v1
from backend.app.task.api.router import v1 as task_v1 from backend.app.task.api.router import v1 as task_v1
router = APIRouter() router = APIRouter()
router.include_router(admin_v1) router.include_router(admin_v1)
router.include_router(generator_v1)
router.include_router(task_v1) router.include_router(task_v1)
+2 -2
View File
@@ -27,7 +27,7 @@ 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='任务ID')]) -> ResponseSchemaModel[TaskResult]: async def get_task_detail(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseSchemaModel[TaskResult]:
status = task_service.get_detail(tid=tid) status = task_service.get_detail(tid=tid)
return response_base.success(data=status) return response_base.success(data=status)
@@ -40,7 +40,7 @@ async def get_task_detail(tid: Annotated[str, Path(description='任务ID')]) ->
DependsRBAC, DependsRBAC,
], ],
) )
async def revoke_task(tid: Annotated[str, Path(description='任务ID')]) -> ResponseModel: async def revoke_task(tid: Annotated[str, Path(description='任务 UUID')]) -> ResponseModel:
task_service.revoke(tid=tid) task_service.revoke(tid=tid)
return response_base.success() return response_base.success()
+40 -35
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any
import celery import celery
import celery_aio_pool import celery_aio_pool
@@ -9,8 +11,39 @@ from backend.core.conf import settings
__all__ = ['celery_app'] __all__ = ['celery_app']
def get_broker_url() -> str:
"""获取消息代理 URL"""
if task_settings.CELERY_BROKER == 'redis':
return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}'
)
return (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}'
)
def get_result_backend() -> str:
"""获取结果后端 URL"""
return (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}'
)
def get_result_backend_transport_options() -> dict[str, Any]:
"""获取结果后端传输选项"""
return {
'global_keyprefix': task_settings.CELERY_BACKEND_REDIS_PREFIX,
'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT,
},
}
def init_celery() -> celery.Celery: def init_celery() -> celery.Celery:
"""初始化 celery 应用""" """初始化 Celery 应用"""
# TODO: Update this work if celery version >= 6.0.0 # TODO: Update this work if celery version >= 6.0.0
# https://github.com/fastapi-practices/fastapi_best_architecture/issues/321 # https://github.com/fastapi-practices/fastapi_best_architecture/issues/321
@@ -18,52 +51,24 @@ def init_celery() -> celery.Celery:
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations() celery.app.trace.reset_worker_optimizations()
# Celery Schedule Tasks
# https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
beat_schedule = task_settings.CELERY_SCHEDULE
# Celery Config
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
broker_url = (
(
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BROKER_REDIS_DATABASE}'
)
if task_settings.CELERY_BROKER == 'redis'
else (
f'amqp://{task_settings.RABBITMQ_USERNAME}:{task_settings.RABBITMQ_PASSWORD}@'
f'{task_settings.RABBITMQ_HOST}:{task_settings.RABBITMQ_PORT}'
)
)
result_backend = (
f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:'
f'{settings.REDIS_PORT}/{task_settings.CELERY_BACKEND_REDIS_DATABASE}'
)
result_backend_transport_options = {
'global_keyprefix': f'{task_settings.CELERY_BACKEND_REDIS_PREFIX}',
'retry_policy': {
'timeout': task_settings.CELERY_BACKEND_REDIS_TIMEOUT,
},
}
app = celery.Celery( app = celery.Celery(
'fba_celery', 'fba_celery',
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
beat_schedule=beat_schedule, beat_schedule=task_settings.CELERY_SCHEDULE,
broker_url=broker_url, broker_url=get_broker_url(),
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=result_backend, result_backend=get_result_backend(),
result_backend_transport_options=result_backend_transport_options, result_backend_transport_options=get_result_backend_transport_options(),
task_cls='app.task.celery_task.base:TaskBase', task_cls='app.task.celery_task.base:TaskBase',
task_track_started=True, task_track_started=True,
) )
# Load task modules # 自动发现任务
app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES) app.autodiscover_tasks(task_settings.CELERY_TASK_PACKAGES)
return app return app
# 创建 celery 实例 # 创建 Celery 实例
celery_app: celery.Celery = init_celery() celery_app: celery.Celery = init_celery()
+30 -5
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import asyncio
from typing import Any
from celery import Task from celery import Task
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@@ -9,16 +12,38 @@ from backend.common.socketio.actions import task_notification
class TaskBase(Task): class TaskBase(Task):
"""任务基类""" """Celery 任务基类"""
autoretry_for = (SQLAlchemyError,) autoretry_for = (SQLAlchemyError,)
max_retries = task_settings.CELERY_TASK_MAX_RETRIES max_retries = task_settings.CELERY_TASK_MAX_RETRIES
async def before_start(self, task_id, args, kwargs): async def before_start(self, task_id: str, args, kwargs) -> None:
"""
任务开始前执行钩子
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 开始执行') await task_notification(msg=f'任务 {task_id} 开始执行')
async def on_success(self, retval, task_id, args, kwargs): async def on_success(self, retval: Any, task_id: str, args, kwargs) -> None:
"""
任务成功后执行钩子
:param retval: 任务返回值
:param task_id: 任务 ID
:return:
"""
await task_notification(msg=f'任务 {task_id} 执行成功') await task_notification(msg=f'任务 {task_id} 执行成功')
async def on_failure(self, exc, task_id, args, kwargs, einfo): def on_failure(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None:
await task_notification(msg=f'任务 {task_id} 执行失败') """
任务失败后执行钩子
:param exc: 异常对象
:param task_id: 任务 ID
:param einfo: 异常信息
:return:
"""
loop = asyncio.get_event_loop()
loop.create_task(task_notification(msg=f'任务 {task_id} 执行失败'))
+1
View File
@@ -7,5 +7,6 @@ from backend.app.task.celery import celery_app
@celery_app.task(name='task_demo_async') @celery_app.task(name='task_demo_async')
async def task_demo_async() -> str: async def task_demo_async() -> str:
"""异步示例任务,模拟耗时操作"""
await sleep(20) await sleep(20)
return 'test async' return 'test async'
+15 -12
View File
@@ -1,35 +1,35 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from functools import lru_cache from functools import lru_cache
from typing import Literal from typing import Any, Literal
from celery.schedules import crontab 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
from backend.core.path_conf import BasePath from backend.core.path_conf import BASE_PATH
class TaskSettings(BaseSettings): class TaskSettings(BaseSettings):
"""Task Settings""" """Celery 任务配置"""
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore') model_config = SettingsConfigDict(env_file=f'{BASE_PATH}/.env', env_file_encoding='utf-8', extra='ignore')
# Env Config # .env 环境
ENVIRONMENT: Literal['dev', 'pro'] ENVIRONMENT: Literal['dev', 'pro']
# Env Celery # .env Redis 配置
CELERY_BROKER_REDIS_DATABASE: int # 仅在 dev 模式时生效 CELERY_BROKER_REDIS_DATABASE: int
CELERY_BACKEND_REDIS_DATABASE: int CELERY_BACKEND_REDIS_DATABASE: int
# Env Rabbitmq # .env RabbitMQ 配置
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest # docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
RABBITMQ_HOST: str RABBITMQ_HOST: str
RABBITMQ_PORT: int RABBITMQ_PORT: int
RABBITMQ_USERNAME: str RABBITMQ_USERNAME: str
RABBITMQ_PASSWORD: str RABBITMQ_PASSWORD: str
# Celery # Celery 基础配置
CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis' CELERY_BROKER: Literal['rabbitmq', 'redis'] = 'redis'
CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:' CELERY_BACKEND_REDIS_PREFIX: str = 'fba:celery:'
CELERY_BACKEND_REDIS_TIMEOUT: int = 5 CELERY_BACKEND_REDIS_TIMEOUT: int = 5
@@ -38,7 +38,9 @@ class TaskSettings(BaseSettings):
'app.task.celery_task.db_log', 'app.task.celery_task.db_log',
] ]
CELERY_TASK_MAX_RETRIES: int = 5 CELERY_TASK_MAX_RETRIES: int = 5
CELERY_SCHEDULE: dict = {
# Celery 定时任务配置
CELERY_SCHEDULE: dict[str, dict[str, Any]] = {
'exec-every-10-seconds': { 'exec-every-10-seconds': {
'task': 'task_demo_async', 'task': 'task_demo_async',
'schedule': 10, 'schedule': 10,
@@ -55,7 +57,8 @@ class TaskSettings(BaseSettings):
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def validate_celery_broker(cls, values): def validate_celery_broker(cls, values: Any) -> Any:
"""生产环境强制使用 RabbitMQ 作为消息代理"""
if values['ENVIRONMENT'] == 'pro': if values['ENVIRONMENT'] == 'pro':
values['CELERY_BROKER'] = 'rabbitmq' values['CELERY_BROKER'] = 'rabbitmq'
return values return values
@@ -63,7 +66,7 @@ class TaskSettings(BaseSettings):
@lru_cache @lru_cache
def get_task_settings() -> TaskSettings: def get_task_settings() -> TaskSettings:
"""获取 task 配置""" """获取 Celery 任务配置"""
return TaskSettings() return TaskSettings()
+17 -11
View File
@@ -1,23 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import Any
from pydantic import Field from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class RunParam(SchemaBase): class RunParam(SchemaBase):
"""任务运行参数"""
name: str = Field(description='任务名称') name: str = Field(description='任务名称')
args: list | None = Field(default=None, description='任务函数位置参数') args: list[Any] | None = Field(None, description='任务函数位置参数')
kwargs: dict | None = Field(default=None, description='任务函数关键字参数') kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
class TaskResult(SchemaBase): class TaskResult(SchemaBase):
result: str """任务执行结果"""
traceback: str
status: str result: str = Field(description='任务执行结果')
name: str traceback: str = Field(description='错误堆栈信息')
args: list | None status: str = Field(description='任务状态')
kwargs: dict | None name: str = Field(description='任务名称')
worker: str args: list[Any] | None = Field(None, description='任务函数位置参数')
retries: int | None kwargs: dict[str, Any] | None = Field(None, description='任务函数关键字参数')
queue: str | None worker: str = Field(description='执行任务的 worker')
retries: int | None = Field(None, description='重试次数')
queue: str | None = Field(None, description='任务队列')
+21 -2
View File
@@ -13,14 +13,21 @@ from backend.common.exception.errors import NotFoundError
class TaskService: class TaskService:
@staticmethod @staticmethod
async def get_list() -> list[str]: async def get_list() -> list[str]:
"""获取所有已注册的 Celery 任务列表"""
registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered) registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered)
if not registered_tasks: if not registered_tasks:
raise errors.ForbiddenError(msg='celery 服务未启动') raise errors.ForbiddenError(msg='Celery 服务未启动')
tasks = list(registered_tasks.values())[0] tasks = list(registered_tasks.values())[0]
return tasks return tasks
@staticmethod @staticmethod
def get_detail(*, tid: str) -> TaskResult: def get_detail(*, tid: str) -> TaskResult:
"""
获取指定任务的详细信息
:param tid: 任务 UUID
:return:
"""
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
@@ -38,7 +45,13 @@ class TaskService:
) )
@staticmethod @staticmethod
def revoke(*, tid: str): def revoke(*, tid: str) -> None:
"""
撤销指定的任务
:param tid: 任务 UUID
:return:
"""
try: try:
result = AsyncResult(id=tid, app=celery_app) result = AsyncResult(id=tid, app=celery_app)
except NotRegistered: except NotRegistered:
@@ -47,6 +60,12 @@ class TaskService:
@staticmethod @staticmethod
def run(*, obj: RunParam) -> str: def run(*, obj: RunParam) -> str:
"""
运行指定的任务
:param obj: 任务运行参数
:return:
"""
task: AsyncResult = celery_app.send_task(name=obj.name, args=obj.args, kwargs=obj.kwargs) task: AsyncResult = celery_app.send_task(name=obj.name, args=obj.args, kwargs=obj.kwargs)
return task.task_id return task.task_id
+21 -160
View File
@@ -2,27 +2,38 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from enum import Enum from enum import Enum
from enum import IntEnum as SourceIntEnum from enum import IntEnum as SourceIntEnum
from typing import Type from typing import Any, Type, TypeVar
T = TypeVar('T', bound=Enum)
class _EnumBase: class _EnumBase:
"""枚举基类,提供通用方法"""
@classmethod @classmethod
def get_member_keys(cls: Type[Enum]) -> list[str]: def get_member_keys(cls: Type[T]) -> list[str]:
"""获取枚举成员名称列表"""
return [name for name in cls.__members__.keys()] return [name for name in cls.__members__.keys()]
@classmethod @classmethod
def get_member_values(cls: Type[Enum]) -> list: def get_member_values(cls: Type[T]) -> list:
"""获取枚举成员值列表"""
return [item.value for item in cls.__members__.values()] return [item.value for item in cls.__members__.values()]
@classmethod
def get_member_dict(cls: Type[T]) -> dict[str, Any]:
"""获取枚举成员字典"""
return {name: item.value for name, item in cls.__members__.items()}
class IntEnum(_EnumBase, SourceIntEnum): class IntEnum(_EnumBase, SourceIntEnum):
"""整型枚举""" """整型枚举基类"""
pass pass
class StrEnum(_EnumBase, str, Enum): class StrEnum(_EnumBase, str, Enum):
"""字符串枚举""" """字符串枚举基类"""
pass pass
@@ -36,14 +47,14 @@ class MenuType(IntEnum):
class RoleDataRuleOperatorType(IntEnum): class RoleDataRuleOperatorType(IntEnum):
"""数据权限规则运算符""" """数据规则运算符"""
AND = 0 AND = 0
OR = 1 OR = 1
class RoleDataRuleExpressionType(IntEnum): class RoleDataRuleExpressionType(IntEnum):
"""数据权限规则表达式""" """数据规则表达式"""
eq = 0 # == eq = 0 # ==
ne = 1 # != ne = 1 # !=
@@ -56,7 +67,7 @@ class RoleDataRuleExpressionType(IntEnum):
class MethodType(StrEnum): class MethodType(StrEnum):
"""请求方法""" """HTTP 请求方法"""
GET = 'GET' GET = 'GET'
POST = 'POST' POST = 'POST'
@@ -67,7 +78,7 @@ class MethodType(StrEnum):
class LoginLogStatusType(IntEnum): class LoginLogStatusType(IntEnum):
"""日志状态""" """日志状态"""
fail = 0 fail = 0
success = 1 success = 1
@@ -100,7 +111,7 @@ class UserSocialType(StrEnum):
"""用户社交类型""" """用户社交类型"""
github = 'GitHub' github = 'GitHub'
linuxdo = 'LinuxDo' linux_do = 'LinuxDo'
class FileType(StrEnum): class FileType(StrEnum):
@@ -108,153 +119,3 @@ class FileType(StrEnum):
image = 'image' image = 'image'
video = 'video' video = 'video'
class GenModelMySQLColumnType(StrEnum):
"""代码生成模型列类型(MySQL"""
# Python 类型映射
BIGINT = 'int'
BigInteger = 'int' # BIGINT
BINARY = 'bytes'
BLOB = 'bytes'
BOOLEAN = 'bool' # BOOL
Boolean = 'bool' # BOOL
CHAR = 'str'
CLOB = 'str'
DATE = 'date'
Date = 'date' # DATE
DATETIME = 'datetime'
DateTime = 'datetime' # DATETIME
DECIMAL = 'Decimal'
DOUBLE = 'float'
Double = 'float' # DOUBLE
DOUBLE_PRECISION = 'float'
Enum = 'Enum' # Enum()
FLOAT = 'float'
Float = 'float' # FLOAT
INT = 'int' # INTEGER
INTEGER = 'int'
Integer = 'int' # INTEGER
Interval = 'timedelta' # DATETIME
JSON = 'dict'
LargeBinary = 'bytes' # BLOB
NCHAR = 'str'
NUMERIC = 'Decimal'
Numeric = 'Decimal' # NUMERIC
NVARCHAR = 'str' # String
PickleType = 'bytes' # BLOB
REAL = 'float'
SMALLINT = 'int'
SmallInteger = 'int' # SMALLINT
String = 'str' # String
TEXT = 'str'
Text = 'str' # TEXT
TIME = 'time'
Time = 'time' # TIME
TIMESTAMP = 'datetime'
Unicode = 'str' # String
UnicodeText = 'str' # TEXT
UUID = 'str | UUID'
Uuid = 'str' # CHAR(32)
VARBINARY = 'bytes'
VARCHAR = 'str' # String
# sa.dialects.mysql 导入
BIT = 'bool'
ENUM = 'Enum'
LONGBLOB = 'bytes'
LONGTEXT = 'str'
MEDIUMBLOB = 'bytes'
MEDIUMINT = 'int'
MEDIUMTEXT = 'str'
SET = 'list[str]'
TINYBLOB = 'bytes'
TINYINT = 'int'
TINYTEXT = 'str'
YEAR = 'int'
class GenModelPostgreSQLColumnType(StrEnum):
"""代码生成模型列类型(PostgreSQL),仅作为数据保留,并未实施"""
# Python 类型映射
BIGINT = 'int'
BigInteger = 'int' # BIGINT
BINARY = 'bytes'
BLOB = 'bytes'
BOOLEAN = 'bool'
Boolean = 'bool' # BOOLEAN
CHAR = 'str'
CLOB = 'str'
DATE = 'date'
Date = 'date' # DATE
DATETIME = 'datetime'
DateTime = 'datetime' # TIMESTAMP WITHOUT TIME ZONE
DECIMAL = 'Decimal'
DOUBLE = 'float'
Double = 'float' # DOUBLE PRECISION
DOUBLE_PRECISION = 'float' # DOUBLE PRECISION
Enum = 'Enum' # Enum(name='enum')
FLOAT = 'float'
Float = 'float' # FLOAT
INT = 'int' # INTEGER
INTEGER = 'int'
Integer = 'int' # INTEGER
Interval = 'timedelta' # INTERVAL
JSON = 'dict'
LargeBinary = 'bytes' # BYTEA
NCHAR = 'str'
NUMERIC = 'Decimal'
Numeric = 'Decimal' # NUMERIC
NVARCHAR = 'str' # String
PickleType = 'bytes' # BYTEA
REAL = 'float'
SMALLINT = 'int'
SmallInteger = 'int' # SMALLINT
String = 'str' # String
TEXT = 'str'
Text = 'str' # TEXT
TIME = 'time' # TIME WITHOUT TIME ZONE
Time = 'time' # TIME WITHOUT TIME ZONE
TIMESTAMP = 'datetime' # TIMESTAMP WITHOUT TIME ZONE
Unicode = 'str' # String
UnicodeText = 'str' # TEXT
UUID = 'str | UUID'
Uuid = 'str'
VARBINARY = 'bytes'
VARCHAR = 'str' # String
# sa.dialects.postgresql 导入
ARRAY = 'list'
BIT = 'bool'
BYTEA = 'bytes'
CIDR = 'str'
CITEXT = 'str'
DATEMULTIRANGE = 'list[date]'
DATERANGE = 'tuple[date, date]'
DOMAIN = 'str'
ENUM = 'Enum'
HSTORE = 'dict'
INET = 'str'
INT4MULTIRANGE = 'list[int]'
INT4RANGE = 'tuple[int, int]'
INT8MULTIRANGE = 'list[int]'
INT8RANGE = 'tuple[int, int]'
INTERVAL = 'timedelta'
JSONB = 'dict'
JSONPATH = 'str'
MACADDR = 'str'
MACADDR8 = 'str'
MONEY = 'Decimal'
NUMMULTIRANGE = 'list[Decimal]'
NUMRANGE = 'tuple[Decimal, Decimal]'
OID = 'int'
REGCLASS = 'str'
REGCONFIG = 'str'
TSMULTIRANGE = 'list[datetime]'
TSQUERY = 'str'
TSRANGE = 'tuple[datetime, datetime]'
TSTZMULTIRANGE = 'list[datetime]'
TSTZRANGE = 'tuple[datetime, datetime]'
TSVECTOR = 'str'
+20 -7
View File
@@ -1,12 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
全局业务异常类
业务代码执行异常时可以使用 raise xxxError 触发内部错误它尽可能实现带有后台任务的异常但它不适用于**自定义响应状态码**
如果要求使用**自定义响应状态码**则可以通过 return response_base.fail(res=CustomResponseCode.xxx) 直接返回
""" # noqa: E501
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
@@ -16,6 +9,8 @@ from backend.common.response.response_code import CustomErrorCode, StandardRespo
class BaseExceptionMixin(Exception): class BaseExceptionMixin(Exception):
"""基础异常混入类"""
code: int code: int
def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None):
@@ -26,17 +21,23 @@ class BaseExceptionMixin(Exception):
class HTTPError(HTTPException): class HTTPError(HTTPException):
"""HTTP 异常"""
def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None): def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None):
super().__init__(status_code=code, detail=msg, headers=headers) super().__init__(status_code=code, detail=msg, headers=headers)
class CustomError(BaseExceptionMixin): class CustomError(BaseExceptionMixin):
"""自定义异常"""
def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None):
self.code = error.code self.code = error.code
super().__init__(msg=error.msg, data=data, background=background) super().__init__(msg=error.msg, data=data, background=background)
class RequestError(BaseExceptionMixin): class RequestError(BaseExceptionMixin):
"""请求异常"""
code = StandardResponseCode.HTTP_400 code = StandardResponseCode.HTTP_400
def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
@@ -44,6 +45,8 @@ class RequestError(BaseExceptionMixin):
class ForbiddenError(BaseExceptionMixin): class ForbiddenError(BaseExceptionMixin):
"""禁止访问异常"""
code = StandardResponseCode.HTTP_403 code = StandardResponseCode.HTTP_403
def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None):
@@ -51,6 +54,8 @@ class ForbiddenError(BaseExceptionMixin):
class NotFoundError(BaseExceptionMixin): class NotFoundError(BaseExceptionMixin):
"""资源不存在异常"""
code = StandardResponseCode.HTTP_404 code = StandardResponseCode.HTTP_404
def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None):
@@ -58,6 +63,8 @@ class NotFoundError(BaseExceptionMixin):
class ServerError(BaseExceptionMixin): class ServerError(BaseExceptionMixin):
"""服务器异常"""
code = StandardResponseCode.HTTP_500 code = StandardResponseCode.HTTP_500
def __init__( def __init__(
@@ -67,6 +74,8 @@ class ServerError(BaseExceptionMixin):
class GatewayError(BaseExceptionMixin): class GatewayError(BaseExceptionMixin):
"""网关异常"""
code = StandardResponseCode.HTTP_502 code = StandardResponseCode.HTTP_502
def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None): def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None):
@@ -74,6 +83,8 @@ class GatewayError(BaseExceptionMixin):
class AuthorizationError(BaseExceptionMixin): class AuthorizationError(BaseExceptionMixin):
"""授权异常"""
code = StandardResponseCode.HTTP_401 code = StandardResponseCode.HTTP_401
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):
@@ -81,6 +92,8 @@ class AuthorizationError(BaseExceptionMixin):
class TokenError(HTTPError): class TokenError(HTTPError):
"""Token 异常"""
code = StandardResponseCode.HTTP_401 code = StandardResponseCode.HTTP_401
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):
+27 -51
View File
@@ -3,7 +3,6 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError from pydantic import ValidationError
from pydantic.errors import PydanticUserError
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
@@ -12,7 +11,6 @@ from backend.common.exception.errors import BaseExceptionMixin
from backend.common.response.response_code import CustomResponseCode, StandardResponseCode from backend.common.response.response_code import CustomResponseCode, StandardResponseCode
from backend.common.response.response_schema import response_base from backend.common.response.response_schema import response_base
from backend.common.schema import ( from backend.common.schema import (
CUSTOM_USAGE_ERROR_MESSAGES,
CUSTOM_VALIDATION_ERROR_MESSAGES, CUSTOM_VALIDATION_ERROR_MESSAGES,
) )
from backend.core.conf import settings from backend.core.conf import settings
@@ -20,36 +18,34 @@ from backend.utils.serializers import MsgSpecJSONResponse
from backend.utils.trace_id import get_request_trace_id from backend.utils.trace_id import get_request_trace_id
def _get_exception_code(status_code: int): def _get_exception_code(status_code: int) -> int:
""" """
获取返回状态码, OpenAPI, Uvicorn... 可用状态码基于 RFC 定义, 详细代码见下方链接 获取返回状态码可用状态码基于 RFC 定义
`python 状态码标准支持 <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http `python 状态码标准支持 <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http/__init__.py#L7>`__
/__init__.py#L7>`__
`IANA 状态码注册表 <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__ `IANA 状态码注册表 <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__
:param status_code: :param status_code: HTTP 状态码
:return: :return:
""" """
try: try:
STATUS_PHRASES[status_code] STATUS_PHRASES[status_code]
return status_code
except Exception: except Exception:
code = StandardResponseCode.HTTP_400 return StandardResponseCode.HTTP_400
else:
code = status_code
return code
async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError): async def _validation_exception_handler(request: Request, exc: RequestValidationError | ValidationError):
""" """
数据验证异常处理 数据验证异常处理
:param e: :param request: 请求对象
:param exc: 验证异常
:return: :return:
""" """
errors = [] errors = []
for error in e.errors(): for error in exc.errors():
custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
if custom_message: if custom_message:
ctx = error.get('ctx') ctx = error.get('ctx')
@@ -89,8 +85,8 @@ def register_exception(app: FastAPI):
""" """
全局 HTTP 异常处理 全局 HTTP 异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: HTTP 异常
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -113,10 +109,10 @@ def register_exception(app: FastAPI):
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError):
""" """
fastapi 数据验证异常处理 FastAPI 数据验证异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 验证异常
:return: :return:
""" """
return await _validation_exception_handler(request, exc) return await _validation_exception_handler(request, exc)
@@ -124,42 +120,21 @@ def register_exception(app: FastAPI):
@app.exception_handler(ValidationError) @app.exception_handler(ValidationError)
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): async def pydantic_validation_exception_handler(request: Request, exc: ValidationError):
""" """
pydantic 数据验证异常处理 Pydantic 数据验证异常处理
:param request: :param request: 请求对象
:param exc: :param exc: 验证异常
:return: :return:
""" """
return await _validation_exception_handler(request, exc) return await _validation_exception_handler(request, exc)
@app.exception_handler(PydanticUserError)
async def pydantic_user_error_handler(request: Request, exc: PydanticUserError):
"""
Pydantic 用户异常处理
:param request:
:param exc:
:return:
"""
content = {
'code': StandardResponseCode.HTTP_500,
'msg': CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
'data': None,
}
request.state.__request_pydantic_user_error__ = content
content.update(trace_id=get_request_trace_id(request))
return MsgSpecJSONResponse(
status_code=StandardResponseCode.HTTP_500,
content=content,
)
@app.exception_handler(AssertionError) @app.exception_handler(AssertionError)
async def assertion_error_handler(request: Request, exc: AssertionError): async def assertion_error_handler(request: Request, exc: AssertionError):
""" """
断言错误处理 断言错误处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 断言错误
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -183,8 +158,8 @@ def register_exception(app: FastAPI):
""" """
全局自定义异常处理 全局自定义异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 自定义异常
:return: :return:
""" """
content = { content = {
@@ -205,8 +180,8 @@ def register_exception(app: FastAPI):
""" """
全局未知异常处理 全局未知异常处理
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 未知异常
:return: :return:
""" """
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
@@ -233,10 +208,11 @@ def register_exception(app: FastAPI):
跨域自定义 500 异常处理 跨域自定义 500 异常处理
`Related issue <https://github.com/encode/starlette/issues/1175>`_ `Related issue <https://github.com/encode/starlette/issues/1175>`_
`Solution <https://github.com/fastapi/fastapi/discussions/7847#discussioncomment-5144709>`_ `Solution <https://github.com/fastapi/fastapi/discussions/7847#discussioncomment-5144709>`_
:param request: :param request: FastAPI 请求对象
:param exc: :param exc: 自定义异常
:return: :return:
""" """
if isinstance(exc, BaseExceptionMixin): if isinstance(exc, BaseExceptionMixin):
+23 -19
View File
@@ -14,18 +14,19 @@ from backend.core.conf import settings
class InterceptHandler(logging.Handler): class InterceptHandler(logging.Handler):
""" """
Default handler from examples in loguru documentation. 日志拦截处理器用于将标准库的日志重定向到 loguru
See https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
参考https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
""" """
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord):
# Get corresponding Loguru level if it exists # 获取对应的 Loguru 级别(如果存在)
try: try:
level = logger.level(record.levelname).name level = logger.level(record.levelname).name
except ValueError: except ValueError:
level = record.levelno level = record.levelno
# Find caller from where originated the logged message. # 查找记录日志消息的调用者
frame, depth = inspect.currentframe(), 0 frame, depth = inspect.currentframe(), 0
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
frame = frame.f_back frame = frame.f_back
@@ -34,16 +35,19 @@ class InterceptHandler(logging.Handler):
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def setup_logging(): def setup_logging() -> None:
""" """
From https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953 设置日志处理器
https://github.com/pawamoy/pawamoy.github.io/issues/17
参考
- https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953
- https://github.com/pawamoy/pawamoy.github.io/issues/17
""" """
# Set the logging handler and level # 设置根日志处理器和级别
logging.root.handlers = [InterceptHandler()] logging.root.handlers = [InterceptHandler()]
logging.root.setLevel(settings.LOG_STD_LEVEL) logging.root.setLevel(settings.LOG_STD_LEVEL)
# Remove all log handlers and propagate to root logger # 配置日志传播规则
for name in logging.root.manager.loggerDict.keys(): for name in logging.root.manager.loggerDict.keys():
logging.getLogger(name).handlers = [] logging.getLogger(name).handlers = []
if 'uvicorn.access' in name or 'watchfiles.main' in name: if 'uvicorn.access' in name or 'watchfiles.main' in name:
@@ -54,17 +58,15 @@ def setup_logging():
# Debug log handlers # Debug log handlers
# logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}') # logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}')
# Define the correlation_id default filter function # 定义 correlation_id 默认过滤函数
# https://github.com/snok/asgi-correlation-id/issues/7 # https://github.com/snok/asgi-correlation-id/issues/7
def correlation_id_filter(record): def correlation_id_filter(record):
cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE) cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE)
record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH] record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH]
return record return record
# Remove default loguru logger # 配置 loguru 处理器
logger.remove() logger.remove() # 移除默认处理器
# Set the loguru default handlers
logger.configure( logger.configure(
handlers=[ handlers=[
{ {
@@ -77,16 +79,17 @@ def setup_logging():
) )
def set_custom_logfile(): def set_custom_logfile() -> None:
"""设置自定义日志文件"""
log_path = path_conf.LOG_DIR log_path = path_conf.LOG_DIR
if not os.path.exists(log_path): if not os.path.exists(log_path):
os.mkdir(log_path) os.mkdir(log_path)
# log files # 日志文件
log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME) log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME)
log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME) log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME)
# set loguru logger default config # 日志文件通用配置
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add # https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
log_config = { log_config = {
'format': settings.LOG_FILE_FORMAT, 'format': settings.LOG_FILE_FORMAT,
@@ -96,7 +99,7 @@ def set_custom_logfile():
'compression': 'tar.gz', 'compression': 'tar.gz',
} }
# stdout file # 标准输出文件
logger.add( logger.add(
str(log_access_file), str(log_access_file),
level=settings.LOG_ACCESS_FILE_LEVEL, level=settings.LOG_ACCESS_FILE_LEVEL,
@@ -106,7 +109,7 @@ def set_custom_logfile():
**log_config, **log_config,
) )
# stderr file # 标准错误文件
logger.add( logger.add(
str(log_error_file), str(log_error_file),
level=settings.LOG_ERROR_FILE_LEVEL, level=settings.LOG_ERROR_FILE_LEVEL,
@@ -117,4 +120,5 @@ def set_custom_logfile():
) )
# 创建 logger 实例
log = logger log = logger
+14 -6
View File
@@ -13,7 +13,7 @@ from backend.utils.timezone import timezone
# 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(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键 ID')
] ]
@@ -38,31 +38,39 @@ class DateTimeMixin(MappedAsDataclass):
class MappedBase(AsyncAttrs, DeclarativeBase): class MappedBase(AsyncAttrs, DeclarativeBase):
""" """
生命式基类, 作为所有基类或数据模型类的父类而存在 声明式基类, 作为所有基类或数据模型类的父类而存在
`AsyncAttrs <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncAttrs>`__ `AsyncAttrs <https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncAttrs>`__
`DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__ `DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__
`mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__ `mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__
""" """
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
"""生成表名"""
return cls.__name__.lower() return cls.__name__.lower()
@declared_attr.directive
def __table_args__(cls) -> dict:
"""表配置"""
return {'comment': cls.__doc__ or ''}
class DataClassBase(MappedAsDataclass, MappedBase): class DataClassBase(MappedAsDataclass, MappedBase):
""" """
声明性数据类基类, 它将带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时 声明性数据类基类, 带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时
`MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__ `MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__
""" # noqa: E501 """
__abstract__ = True __abstract__ = True
class Base(DataClassBase, DateTimeMixin): class Base(DataClassBase, DateTimeMixin):
""" """
声明性 Mixin 数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构, 你可以简单的理解它为含有基础表结构的数据类基类 声明性数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构
""" # noqa: E501 """
__abstract__ = True __abstract__ = True
+33 -25
View File
@@ -3,12 +3,12 @@
from __future__ import annotations from __future__ import annotations
from math import ceil from math import ceil
from typing import TYPE_CHECKING, Generic, Sequence, TypeVar from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
from fastapi import Depends, Query from fastapi import Depends, Query
from fastapi_pagination import pagination_ctx from fastapi_pagination import pagination_ctx
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.ext.sqlalchemy import apaginate
from fastapi_pagination.links.bases import create_links from fastapi_pagination.links.bases import create_links
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,8 +21,10 @@ SchemaT = TypeVar('SchemaT')
class _CustomPageParams(BaseModel, AbstractParams): class _CustomPageParams(BaseModel, AbstractParams):
page: int = Query(1, ge=1, description='Page number') """自定义分页参数"""
size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录
page: int = Query(1, ge=1, description='页码')
size: int = Query(20, gt=0, le=200, description='每页数量')
def to_raw_params(self) -> RawParams: def to_raw_params(self) -> RawParams:
return RawParams( return RawParams(
@@ -32,47 +34,53 @@ class _CustomPageParams(BaseModel, AbstractParams):
class _Links(BaseModel): class _Links(BaseModel):
first: str = Field(..., description='首页链接') """分页链接"""
last: str = Field(..., description='尾页链接')
self: str = Field(..., description='当前页链接') first: str = Field(description='页链接')
last: str = Field(description='尾页链接')
self: str = Field(description='当前页链接')
next: str | None = Field(None, description='下一页链接') next: str | None = Field(None, description='下一页链接')
prev: str | None = Field(None, description='上一页链接') prev: str | None = Field(None, description='上一页链接')
class _PageDetails(BaseModel): class _PageDetails(BaseModel):
items: list = Field([], description='当前页数据') """分页详情"""
total: int = Field(..., description='总条数')
page: int = Field(..., description='当前页') items: list = Field([], description='当前页数据列表')
size: int = Field(..., description='每页数量') total: int = Field(description='数据总条数')
total_pages: int = Field(..., description='总页数') page: int = Field(description='当前页码')
links: _Links size: int = Field(description='每页数量')
total_pages: int = Field(description='总页数')
links: _Links = Field(description='分页链接')
class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]): class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
"""自定义分页类"""
__params_type__ = _CustomPageParams __params_type__ = _CustomPageParams
@classmethod @classmethod
def create( def create(
cls, cls,
items: list, items: list,
total: int,
params: _CustomPageParams, params: _CustomPageParams,
total: int = 0,
) -> _CustomPage[T]: ) -> _CustomPage[T]:
page = params.page page = params.page
size = params.size size = params.size
total_pages = ceil(total / params.size) total_pages = ceil(total / size)
links = create_links( links = create_links(
first={'page': 1, 'size': size}, first={'page': 1, 'size': size},
last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size}, last={'page': total_pages, 'size': size} if total > 0 else {'page': 1, 'size': size},
next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None, next={'page': page + 1, 'size': size} if (page + 1) <= total_pages else None,
prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None, prev={'page': page - 1, 'size': size} if (page - 1) >= 1 else None,
).model_dump() ).model_dump()
return cls( return cls(
items=items, items=items,
total=total, total=total,
page=params.page, page=page,
size=params.size, size=size,
total_pages=total_pages, total_pages=total_pages,
links=links, # type: ignore links=links, # type: ignore
) )
@@ -80,7 +88,7 @@ class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
class PageData(_PageDetails, Generic[SchemaT]): class PageData(_PageDetails, Generic[SchemaT]):
""" """
包含 data schema 的统一返回模型适用于分页接口 包含返回数据 schema 的统一返回模型适用于分页接口
E.g. :: E.g. ::
@@ -103,15 +111,15 @@ class PageData(_PageDetails, Generic[SchemaT]):
items: Sequence[SchemaT] items: Sequence[SchemaT]
async def paging_data(db: AsyncSession, select: Select) -> dict: async def paging_data(db: AsyncSession, select: Select) -> dict[str, Any]:
""" """
基于 SQLAlchemy 创建分页数据 基于 SQLAlchemy 创建分页数据
:param db: :param db: 数据库会话
:param select: :param select: SQL 查询语句
:return: :return:
""" """
paginated_data: _CustomPage = await paginate(db, select) paginated_data: _CustomPage = await apaginate(db, select)
page_data = paginated_data.model_dump() page_data = paginated_data.model_dump()
return page_data return page_data
+4 -8
View File
@@ -9,17 +9,13 @@ class CustomCodeBase(Enum):
"""自定义状态码基类""" """自定义状态码基类"""
@property @property
def code(self): def code(self) -> int:
""" """获取状态码"""
获取状态码
"""
return self.value[0] return self.value[0]
@property @property
def msg(self): def msg(self) -> str:
""" """获取状态码信息"""
获取状态码信息
"""
return self.value[1] return self.value[1]
+27 -11
View File
@@ -3,7 +3,7 @@
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from fastapi import Response from fastapi import Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from backend.common.response.response_code import CustomResponse, CustomResponseCode from backend.common.response.response_code import CustomResponse, CustomResponseCode
from backend.utils.serializers import MsgSpecJSONResponse from backend.utils.serializers import MsgSpecJSONResponse
@@ -13,9 +13,9 @@ SchemaT = TypeVar('SchemaT')
class ResponseModel(BaseModel): class ResponseModel(BaseModel):
""" """
通用型统一返回模型不包含 data schema 不包含返回数据 schema 通用型统一返回模型
E.g. :: 示例::
@router.get('/test', response_model=ResponseModel) @router.get('/test', response_model=ResponseModel)
def test(): def test():
@@ -33,16 +33,16 @@ class ResponseModel(BaseModel):
return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'}) return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'})
""" """
code: int = CustomResponseCode.HTTP_200.code code: int = Field(CustomResponseCode.HTTP_200.code, description='返回状态码')
msg: str = CustomResponseCode.HTTP_200.msg msg: str = Field(CustomResponseCode.HTTP_200.msg, description='返回信息')
data: Any | None = None data: Any | None = Field(None, description='返回数据')
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
""" """
包含 data schema 的统一返回模型适用于非分页接口 包含返回数据 schema 通用型统一返回模型适用于非分页接口
E.g. :: 示例::
@router.get('/test', response_model=ResponseSchemaModel[GetApiDetail]) @router.get('/test', response_model=ResponseSchemaModel[GetApiDetail])
def test(): def test():
@@ -68,7 +68,9 @@ class ResponseBase:
@staticmethod @staticmethod
def __response( def __response(
*, res: CustomResponseCode | CustomResponse = None, data: Any | None = None *,
res: CustomResponseCode | CustomResponse,
data: Any | None,
) -> ResponseModel | ResponseSchemaModel: ) -> ResponseModel | ResponseSchemaModel:
""" """
请求返回通用方法 请求返回通用方法
@@ -85,6 +87,13 @@ class ResponseBase:
res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200,
data: Any | None = None, data: Any | None = None,
) -> ResponseModel | ResponseSchemaModel: ) -> ResponseModel | ResponseSchemaModel:
"""
成功响应
:param res: 返回信息
:param data: 返回数据
:return:
"""
return self.__response(res=res, data=data) return self.__response(res=res, data=data)
def fail( def fail(
@@ -93,6 +102,13 @@ class ResponseBase:
res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400, res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400,
data: Any = None, data: Any = None,
) -> ResponseModel | ResponseSchemaModel: ) -> ResponseModel | ResponseSchemaModel:
"""
失败响应
:param res: 返回信息
:param data: 返回数据
:return:
"""
return self.__response(res=res, data=data) return self.__response(res=res, data=data)
@staticmethod @staticmethod
@@ -108,8 +124,8 @@ class ResponseBase:
使用此返回方法时不能指定接口参数 response_model 和箭头返回类型 使用此返回方法时不能指定接口参数 response_model 和箭头返回类型
:param res: :param res: 返回信息
:param data: :param data: 返回数据
:return: :return:
""" """
return MsgSpecJSONResponse({'code': res.code, 'msg': res.msg, 'data': data}) return MsgSpecJSONResponse({'code': res.code, 'msg': res.msg, 'data': data})
+103 -134
View File
@@ -1,158 +1,127 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, validate_email from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email
from pydantic_extra_types.phone_numbers import PhoneNumber
from backend.core.conf import settings from backend.core.conf import settings
# 自定义验证错误信息不包含验证预期内容(也就是输入内容),受支持的预期内容字段参考以下链接 # 自定义验证错误信息,参考:
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 # https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
# 替换预期内容字段方式,参考以下链接
# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 # https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
CUSTOM_VALIDATION_ERROR_MESSAGES = { CUSTOM_VALIDATION_ERROR_MESSAGES = {
'arguments_type': '参数类型输入错误', 'no_such_attribute': "对象没有属性 '{attribute}'",
'assertion_error': '断言执行错误', 'json_invalid': '无效的 JSON: {error}',
'bool_parsing': '布尔值输入解析错误', 'json_type': 'JSON 输入应为字符串、字节或字节数组',
'bool_type': '布尔值类型输入错误', 'recursion_loop': '递归错误 - 检测到循环引用',
'bytes_too_long': '字节长度输入过长', 'model_type': '输入应为有效的字典或 {class_name} 的实例',
'bytes_too_short': '字节长度输入过短', 'model_attributes_type': '输入应为有效的字典或可提取字段的对象',
'bytes_type': '字节类型输入错误', 'dataclass_exact_type': '输入应为 {class_name} 的实例',
'callable_type': '可调用对象类型输入错误', 'dataclass_type': '输入应为字典或 {class_name} 的实例',
'dataclass_exact_type': '数据类实例类型输入错误', 'missing': '字段为必填项',
'dataclass_type': '数据类类型输入错误', 'frozen_field': '字段已冻结',
'date_from_datetime_inexact': '日期分量输入非零', 'frozen_instance': '实例已冻结',
'date_from_datetime_parsing': '日期输入解析错误', 'extra_forbidden': '不允许额外的输入',
'date_future': '日期输入非将来时', 'invalid_key': '键应为字符串',
'date_parsing': '日期输入验证错误', 'get_attribute_error': '提取属性时出错: {error}',
'date_past': '日期输入非过去时', 'none_required': '输入应为 None',
'date_type': '日期类型输入错误', 'enum': '输入应为 {expected}',
'datetime_future': '日期时间输入非将来时间', 'greater_than': '输入应大于 {gt}',
'datetime_object_invalid': '日期时间输入对象无效', 'greater_than_equal': '输入应大于或等于 {ge}',
'datetime_parsing': '日期时间输入解析错误', 'less_than': '输入应小于 {lt}',
'datetime_past': '日期时间输入非过去时间', 'less_than_equal': '输入应小于或等于 {le}',
'datetime_type': '日期时间类型输入错误', 'finite_number': '输入应为有限数字',
'decimal_max_digits': '小数位数输入过多', 'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}',
'decimal_max_places': '小数位数输入错误', 'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}',
'decimal_parsing': '小数输入解析错误', 'string_type': '输入应为有效的字符串',
'decimal_type': '小数类型输入错误', 'string_sub_type': '输入应为字符串,而不是 str 子类的实例',
'decimal_whole_digits': '小数位数输入错误', 'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串',
'dict_type': '字典类型输入错误', 'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'",
'enum': '枚举成员输入错误,允许 {expected}', 'string_too_short': '字符串应至少有 {min_length} 个字符',
'extra_forbidden': '禁止额外字段输入', 'string_too_long': '字符串最多应有 {max_length} 个字符',
'finite_number': '有限值输入错误', 'dict_type': '输入应为有效的字典',
'float_parsing': '浮点数输入解析错误', 'mapping_type': '输入应为有效的映射,错误: {error}',
'float_type': '浮点数类型输入错误', 'iterable_type': '输入应为可迭代对象',
'frozen_field': '冻结字段输入错误', 'iteration_error': '迭代对象时出错,错误: {error}',
'frozen_instance': '冻结实例禁止修改', 'list_type': '输入应为有效的列表',
'frozen_set_type': '冻结类型禁止输入', 'tuple_type': '输入应为有效的元组',
'get_attribute_error': '获取属性错误', 'set_type': '输入应为有效的集合',
'greater_than': '输入值过大', 'bool_type': '输入应为有效的布尔',
'greater_than_equal': '输入值过大或相等', 'bool_parsing': '输入应为有效的布尔值,无法解释输入',
'int_from_float': '整数类型输入错误', 'int_type': '输入应为有效的整数',
'int_parsing': '整数输入解析错误', 'int_parsing': '输入应为有效的整数,无法将字符串解析为整数',
'int_parsing_size': '整数输入解析长度错误', 'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小',
'int_type': '整数类型输入错误', 'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字',
'invalid_key': '输入无效键值', 'multiple_of': '输入应为 {multiple_of} 的倍数',
'is_instance_of': '类型实例输入错误', 'float_type': '输入应为有效的数字',
'is_subclass_of': '类型子类输入错误', 'float_parsing': '输入应为有效的数字,无法将字符串解析为数字',
'iterable_type': '可迭代类型输入错误', 'bytes_type': '输入应为有效的字节',
'iteration_error': '迭代值输入错误', 'bytes_too_short': '数据应至少有 {min_length} 个字节',
'json_invalid': 'JSON 字符串输入错误', 'bytes_too_long': '数据最多应有 {max_length} 个字节',
'json_type': 'JSON 类型输入错误', 'value_error': '值错误,{error}',
'less_than': '输入值过小', 'assertion_error': '断言失败,{error}',
'less_than_equal': '输入值过小或相等', 'literal_error': '输入应为 {expected}',
'list_type': '列表类型输入错误', 'date_type': '输入应为有效的日期',
'literal_error': '字面值输入错误', 'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}',
'mapping_type': '映射类型输入错误', 'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}',
'missing': '缺少必填字段', 'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期',
'missing_argument': '缺少参数', 'date_past': '日期应为过去的时间',
'missing_keyword_only_argument': '缺少关键字参数', 'date_future': '日期应为未来的时间',
'missing_positional_only_argument': '缺少位置参数', 'time_type': '输入应为有效的时间',
'model_attributes_type': '模型属性类型输入错误', 'time_parsing': '输入应为有效的时间格式,{error}',
'model_type': '模型实例输入错误', 'datetime_type': '输入应为有效的日期时间',
'multiple_argument_values': '参数值输入过多', 'datetime_parsing': '输入应为有效的日期时间,{error}',
'multiple_of': '输入值非倍数', 'datetime_object_invalid': '无效的日期时间对象,得到 {error}',
'no_such_attribute': '分配无效属性值', 'datetime_past': '输入应为过去的时间',
'none_required': '输入值必须为 None', 'datetime_future': '输入应为未来的时间',
'recursion_loop': '输入循环赋值', 'timezone_naive': '输入不应包含时区信息',
'set_type': '集合类型输入错误', 'timezone_aware': '输入应包含时区信息',
'string_pattern_mismatch': '字符串约束模式输入不匹配', 'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}',
'string_sub_type': '字符串子类型(非严格实例)输入错误', 'time_delta_type': '输入应为有效的时间差',
'string_too_long': '字符串输入过长', 'time_delta_parsing': '输入应为有效的时间差,{error}',
'string_too_short': '字符串输入过短', 'frozen_set_type': '输入应为有效的冻结集合',
'string_type': '字符串类型输入错误', 'is_instance_of': '输入应为 {class} 的实例',
'string_unicode': '字符串输入非 Unicode', 'is_subclass_of': '输入应为 {class} 的子类',
'time_delta_parsing': '时间差输入解析错误', 'callable_type': '输入应为可调用对象',
'time_delta_type': '时间差类型输入错误', 'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}",
'time_parsing': '时间输入解析错误', 'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签',
'time_type': '时间类型输入错误', 'arguments_type': '参数必须是元组、列表或字典',
'timezone_aware': '缺少时区输入信息', 'missing_argument': '缺少必需参数',
'timezone_naive': '禁止时区输入信息', 'unexpected_keyword_argument': '意外的关键字参数',
'too_long': '输入过长', 'missing_keyword_only_argument': '缺少必需的关键字专用参数',
'too_short': '输入过短', 'unexpected_positional_argument': '意外的位置参数',
'tuple_type': '元组类型输入错误', 'missing_positional_only_argument': '缺少必需的位置专用参数',
'unexpected_keyword_argument': '输入意外关键字参数', 'multiple_argument_values': '为参数提供了多个值',
'unexpected_positional_argument': '输入意外位置参数', 'url_type': 'URL 输入应为字符串或 URL',
'union_tag_invalid': '联合类型字面值输入错误', 'url_parsing': '输入应为有效的 URL{error}',
'union_tag_not_found': '联合类型参数输入未找到', 'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}',
'url_parsing': 'URL 输入解析错误', 'url_too_long': 'URL 最多应有 {max_length} 个字符',
'url_scheme': 'URL 输入方案错误', 'url_scheme': 'URL 方案应为 {expected_schemes}',
'url_syntax_violation': 'URL 输入语法错误', 'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象',
'url_too_long': 'URL 输入过长', 'uuid_parsing': '输入应为有效的 UUID{error}',
'url_type': 'URL 类型输入错误', 'uuid_version': '预期 UUID 版本为 {expected_version}',
'uuid_parsing': 'UUID 输入解析错误', 'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象',
'uuid_type': 'UUID 类型输入错误', 'decimal_parsing': '输入应为有效的十进制数',
'uuid_version': 'UUID 版本类型输入错误', 'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字',
'value_error': '值输入错误', 'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数',
'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字',
} }
CUSTOM_USAGE_ERROR_MESSAGES = { CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')]
'class-not-fully-defined': '类属性类型未完全定义',
'custom-json-schema': '__modify_schema__ 方法在V2中已被弃用',
'decorator-missing-field': '定义了无效字段验证器',
'discriminator-no-field': '鉴别器字段未全部定义',
'discriminator-alias-type': '鉴别器字段使用非字符串类型定义',
'discriminator-needs-literal': '鉴别器字段需要使用字面值定义',
'discriminator-alias': '鉴别器字段别名定义不一致',
'discriminator-validator': '鉴别器字段禁止定义字段验证器',
'model-field-overridden': '无类型定义字段禁止重写',
'model-field-missing-annotation': '缺少字段类型定义',
'config-both': '重复定义配置项',
'removed-kwargs': '调用已移除的关键字配置参数',
'invalid-for-json-schema': '存在无效的 JSON 类型',
'base-model-instantiated': '禁止实例化基础模型',
'undefined-annotation': '缺少类型定义',
'schema-for-unknown-type': '未知类型定义',
'create-model-field-definitions': '字段定义错误',
'create-model-config-base': '配置项定义错误',
'validator-no-fields': '字段验证器未指定字段',
'validator-invalid-fields': '字段验证器字段定义错误',
'validator-instance-method': '字段验证器必须为类方法',
'model-serializer-instance-method': '序列化器必须为实例方法',
'validator-v1-signature': 'V1字段验证器错误已被弃用',
'validator-signature': '字段验证器签名错误',
'field-serializer-signature': '字段序列化器签名无法识别',
'model-serializer-signature': '模型序列化器签名无法识别',
'multiple-field-serializers': '字段序列化器重复定义',
'invalid_annotated_type': '无效的类型定义',
'type-adapter-config-unused': '类型适配器配置项定义错误',
'root-model-extra': '根模型禁止定义额外字段',
}
class CustomPhoneNumber(PhoneNumber):
default_region_code = 'CN'
class CustomEmailStr(EmailStr): class CustomEmailStr(EmailStr):
"""自定义邮箱类型"""
@classmethod @classmethod
def _validate(cls, __input_value: str) -> str: def _validate(cls, __input_value: str) -> str:
return None if __input_value == '' else validate_email(__input_value)[1] return None if __input_value == '' else validate_email(__input_value)[1]
class SchemaBase(BaseModel): class SchemaBase(BaseModel):
"""基础模型配置"""
model_config = ConfigDict( model_config = ConfigDict(
use_enum_values=True, use_enum_values=True,
json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)}, json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)},
+92 -64
View File
@@ -3,6 +3,7 @@
import json import json
from datetime import timedelta from datetime import timedelta
from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import Depends, Request from fastapi import Depends, Request
@@ -15,7 +16,7 @@ from pydantic_core import from_json
from sqlalchemy.ext.asyncio import AsyncSession 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 CurrentUserIns 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.errors import AuthorizationError, TokenError
from backend.core.conf import settings from backend.core.conf import settings
@@ -32,10 +33,10 @@ password_hash = PasswordHash((BcryptHasher(),))
def get_hash_password(password: str, salt: bytes | None) -> str: def get_hash_password(password: str, salt: bytes | None) -> str:
""" """
Encrypt passwords using the hash algorithm 使用哈希算法加密密码
:param password: :param password: 密码
:param salt: :param salt: 盐值
:return: :return:
""" """
return password_hash.hash(password, salt=salt) return password_hash.hash(password, salt=salt)
@@ -43,33 +44,68 @@ def get_hash_password(password: str, salt: bytes | None) -> str:
def password_verify(plain_password: str, hashed_password: str) -> bool: def password_verify(plain_password: str, hashed_password: str) -> bool:
""" """
Password verification 密码验证
:param plain_password: The password to verify :param plain_password: 待验证的密码
:param hashed_password: The hash ciphers to compare :param hashed_password: 哈希密码
:return: :return:
""" """
return password_hash.verify(plain_password, hashed_password) return password_hash.verify(plain_password, hashed_password)
async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> AccessToken: def jwt_encode(payload: dict[str, Any]) -> str:
""" """
Generate encryption token 生成 JWT token
:param user_id: The user id of the JWT :param payload: 载荷
:param multi_login: Multipoint login for user
:param kwargs: Token extra information
:return: :return:
""" """
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS) return jwt.encode(
session_uuid = str(uuid4()) payload,
access_token = jwt.encode(
{'session_uuid': session_uuid, 'exp': expire, 'sub': user_id},
settings.TOKEN_SECRET_KEY, settings.TOKEN_SECRET_KEY,
settings.TOKEN_ALGORITHM, settings.TOKEN_ALGORITHM,
) )
if multi_login is False:
def jwt_decode(token: str) -> TokenPayload:
"""
解析 JWT token
:param token: JWT token
:return:
"""
try:
payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM])
session_uuid = payload.get('session_uuid') or 'debug'
user_id = payload.get('sub')
expire_time = payload.get('exp')
if not user_id:
raise TokenError(msg='Token 无效')
except ExpiredSignatureError:
raise TokenError(msg='Token 已过期')
except (JWTError, Exception):
raise TokenError(msg='Token 无效')
return TokenPayload(id=int(user_id), session_uuid=session_uuid, expire_time=expire_time)
async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> AccessToken:
"""
生成加密 token
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:param kwargs: token 额外信息
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
session_uuid = str(uuid4())
access_token = jwt_encode({
'session_uuid': session_uuid,
'exp': expire,
'sub': user_id,
})
if not multi_login:
await redis_client.delete_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}') await redis_client.delete_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
await redis_client.setex( await redis_client.setex(
@@ -91,20 +127,16 @@ async def create_access_token(user_id: str, multi_login: bool, **kwargs) -> Acce
async def create_refresh_token(user_id: str, multi_login: bool) -> RefreshToken: async def create_refresh_token(user_id: str, multi_login: bool) -> RefreshToken:
""" """
Generate encryption refresh token, only used to create a new token 生成加密刷新 token仅用于创建新的 token
:param user_id: The user id of the JWT :param user_id: 用户 ID
:param multi_login: multipoint login for user :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( refresh_token = jwt_encode({'exp': expire, 'sub': user_id})
{'exp': expire, 'sub': user_id},
settings.TOKEN_SECRET_KEY,
settings.TOKEN_ALGORITHM,
)
if multi_login is False: if not multi_login:
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}' key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}'
await redis_client.delete_prefix(key_prefix) await redis_client.delete_prefix(key_prefix)
@@ -118,12 +150,12 @@ async def create_refresh_token(user_id: str, multi_login: bool) -> RefreshToken:
async def create_new_token(user_id: str, refresh_token: str, multi_login: bool, **kwargs) -> NewToken: async def create_new_token(user_id: str, refresh_token: str, multi_login: bool, **kwargs) -> NewToken:
""" """
Generate new token 生成新的 token
:param user_id: :param user_id: 用户 ID
:param refresh_token: :param refresh_token: 刷新 token
:param multi_login: :param multi_login: 是否允许多端登录
:param kwargs: Access token extra information :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}:{refresh_token}')
@@ -137,10 +169,23 @@ async def create_new_token(user_id: str, refresh_token: str, multi_login: bool,
) )
async def revoke_token(user_id: str, session_uuid: str) -> None:
"""
撤销 token
:param user_id: 用户 ID
:param session_uuid: 会话 ID
:return:
"""
token_key = f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}'
await redis_client.delete(token_key)
def get_token(request: Request) -> str: def get_token(request: Request) -> str:
""" """
Get token for request header 获取请求头中的 token
:param request: FastAPI 请求对象
:return: :return:
""" """
authorization = request.headers.get('Authorization') authorization = request.headers.get('Authorization')
@@ -150,33 +195,12 @@ def get_token(request: Request) -> str:
return token return token
def jwt_decode(token: str) -> TokenPayload:
"""
Decode token
:param token:
:return:
"""
try:
payload = jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM])
session_uuid = payload.get('session_uuid') or 'debug'
user_id = payload.get('sub')
expire_time = payload.get('exp')
if not user_id:
raise TokenError(msg='Token 无效')
except ExpiredSignatureError:
raise TokenError(msg='Token 已过期')
except (JWTError, Exception):
raise TokenError(msg='Token 无效')
return TokenPayload(id=int(user_id), session_uuid=session_uuid, expire_time=expire_time)
async def get_current_user(db: AsyncSession, pk: int) -> User: async def get_current_user(db: AsyncSession, pk: int) -> User:
""" """
Get the current user through token 获取当前用户
:param db: :param db: 数据库会话
:param pk: :param pk: 用户 ID
:return: :return:
""" """
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
@@ -200,9 +224,9 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
def superuser_verify(request: Request) -> bool: def superuser_verify(request: Request) -> bool:
""" """
Verify the current user permissions through token 验证当前用户权限
:param request: :param request: FastAPI 请求对象
:return: :return:
""" """
superuser = request.user.is_superuser superuser = request.user.is_superuser
@@ -211,23 +235,27 @@ def superuser_verify(request: Request) -> bool:
return superuser return superuser
async def jwt_authentication(token: str) -> CurrentUserIns: async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
""" """
JWT authentication JWT 认证
:param token: :param token: JWT token
:return: :return:
""" """
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
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 or token != redis_token: if not redis_token:
raise TokenError(msg='Token 已过期') raise TokenError(msg='Token 已过期')
if token != redis_token:
raise 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:
async with async_db_session() as db: async with async_db_session() as db:
current_user = await get_current_user(db, user_id) current_user = await get_current_user(db, user_id)
user = CurrentUserIns(**select_as_dict(current_user)) user = GetUserInfoWithRelationDetail(**select_as_dict(current_user))
await redis_client.setex( await redis_client.setex(
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}', f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
settings.JWT_USER_REDIS_EXPIRE_SECONDS, settings.JWT_USER_REDIS_EXPIRE_SECONDS,
@@ -236,5 +264,5 @@ async def jwt_authentication(token: str) -> CurrentUserIns:
else: else:
# TODO: 在恰当的时机,应替换为使用 model_validate_json # TODO: 在恰当的时机,应替换为使用 model_validate_json
# https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing # https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing
user = CurrentUserIns.model_validate(from_json(cache_user, allow_partial=True)) user = GetUserInfoWithRelationDetail.model_validate(from_json(cache_user, allow_partial=True))
return user return user
+32 -19
View File
@@ -17,33 +17,48 @@ if TYPE_CHECKING:
class RequestPermission: class RequestPermission:
""" """
请求权限用于角色菜单RBAC 请求权限验证器用于角色菜单 RBAC 权限控制
Tip: 注意
使用此请求权限时需要将 `Depends(RequestPermission('xxx'))` `DependsRBAC` 之前设置 使用此请求权限时需要将 `Depends(RequestPermission('xxx'))` `DependsRBAC` 之前设置
因为 fastapi 当前版本的接口依赖注入按正序执行意味着 RBAC 标识会在验证前被设置 因为 FastAPI 当前版本的接口依赖注入按正序执行意味着 RBAC 标识会在验证前被设置
""" """
def __init__(self, value: str): def __init__(self, value: str) -> None:
"""
初始化请求权限验证器
:param value: 权限标识
:return:
"""
self.value = value self.value = value
async def __call__(self, request: Request): async def __call__(self, request: Request) -> None:
"""
验证请求权限
:param request: FastAPI 请求对象
:return:
"""
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 ServerError
# 附加权限标识 # 附加权限标识到请求状态
request.state.permission = self.value request.state.permission = self.value
def filter_data_permission(request: Request) -> ColumnElement[bool]: def filter_data_permission(request: Request) -> ColumnElement[bool]:
""" """
过滤数据权限 过滤数据权限控制用户可见数据范围
使用场景用户登录前台后控制其能看到哪些数据 使用场景
- 用户登录前台后控制其能看到哪些数据
- 根据用户角色和规则过滤数据访问权限
:param request: :param request: FastAPI 请求对象
:return: :return:
""" """
# 获取用户角色和规则
data_rules = [] data_rules = []
for role in request.user.roles: for role in request.user.roles:
data_rules.extend(role.rules) data_rules.extend(role.rules)
@@ -57,13 +72,13 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]:
where_or_list = [] where_or_list = []
for rule in user_data_rules: for rule in user_data_rules:
# 验证规则模型
rule_model = rule.model rule_model = rule.model
if rule_model not in settings.DATA_PERMISSION_MODELS: if rule_model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则模型不存在') raise errors.NotFoundError(msg='数据规则模型不存在')
try:
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model]) model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[rule_model])
except (ImportError, AttributeError):
raise errors.ServerError(msg=f'数据模型 {rule_model} 动态导入失败,请联系系统超级管理员') # 验证规则列
model_columns = [ model_columns = [
key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE key for key in model_ins.__table__.columns.keys() if key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
@@ -71,11 +86,9 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]:
if column not in model_columns: if column not in model_columns:
raise errors.NotFoundError(msg='数据规则模型列不存在') raise errors.NotFoundError(msg='数据规则模型列不存在')
# 获取模型的列对象 # 构建过滤条件
column_obj = getattr(model_ins, column) column_obj = getattr(model_ins, column)
rule_expression = rule.expression rule_expression = rule.expression
# 根据表达式类型构建条件
condition = None condition = None
if rule_expression == RoleDataRuleExpressionType.eq: if rule_expression == RoleDataRuleExpressionType.eq:
condition = column_obj == rule.value condition = column_obj == rule.value
@@ -96,14 +109,14 @@ def filter_data_permission(request: Request) -> ColumnElement[bool]:
values = rule.value.split(',') if isinstance(rule.value, str) else rule.value values = rule.value.split(',') if isinstance(rule.value, str) else rule.value
condition = ~column_obj.in_(values) condition = ~column_obj.in_(values)
# 根据运算符添加到对应列表
if condition is not None: if condition is not None:
rule_operator = rule.operator if rule.operator == RoleDataRuleOperatorType.AND:
if rule_operator == RoleDataRuleOperatorType.AND:
where_and_list.append(condition) where_and_list.append(condition)
elif rule_operator == RoleDataRuleOperatorType.OR: elif rule.operator == RoleDataRuleOperatorType.OR:
where_or_list.append(condition) where_or_list.append(condition)
# 组合条件 # 组合所有条件
where_list = [] where_list = []
if where_and_list: if where_and_list:
where_list.append(and_(*where_and_list)) where_list.append(and_(*where_and_list))
+12 -3
View File
@@ -3,18 +3,20 @@
from fastapi import Depends, Request 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.errors import AuthorizationError, TokenError from backend.common.exception.errors import AuthorizationError, TokenError
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
from backend.plugin.casbin.utils.rbac import casbin_verify from backend.utils.import_parse import import_module_cached
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
""" """
RBAC 权限校验鉴权顺序很重要谨慎修改 RBAC 权限校验鉴权顺序很重要谨慎修改
:param request: :param request: FastAPI 请求对象
:param _token: :param _token: JWT 令牌
:return: :return:
""" """
path = request.url.path path = request.url.path
@@ -67,6 +69,13 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
if path_auth_perm not in allow_perms: if path_auth_perm not in allow_perms:
raise AuthorizationError raise AuthorizationError
else: else:
try:
casbin_rbac = import_module_cached('backend.plugin.casbin.utils.rbac')
casbin_verify = getattr(casbin_rbac, 'casbin_verify')
except (ImportError, AttributeError) as e:
log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}')
raise errors.ServerError(msg='权限校验失败,请联系系统管理员')
await casbin_verify(request) await casbin_verify(request)
+1 -1
View File
@@ -7,7 +7,7 @@ async def task_notification(msg: str):
""" """
任务通知 任务通知
:param msg: :param msg: 通知信息
:return: :return:
""" """
await sio.emit('task_notification', {'msg': msg}) await sio.emit('task_notification', {'msg': msg})
+8 -7
View File
@@ -8,8 +8,9 @@ from backend.common.security.jwt import jwt_authentication
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
# 创建 Socket.IO 服务器实例
sio = socketio.AsyncServer( sio = socketio.AsyncServer(
# 此配置是为了集成 celery 实现消息订阅,如果你不使用 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}/{task_settings.CELERY_BROKER_REDIS_DATABASE}'
@@ -30,15 +31,15 @@ sio = socketio.AsyncServer(
@sio.event @sio.event
async def connect(sid, environ, auth): async def connect(sid, environ, auth):
"""当客户端连接时触发""" """处理 WebSocket 连接事件"""
if not auth: if not auth:
log.error('ws 连接失败:无授权') log.error('WebSocket 连接失败:无授权')
return False return False
session_uuid = auth.get('session_uuid') session_uuid = auth.get('session_uuid')
token = auth.get('token') token = auth.get('token')
if not token or not session_uuid: if not token or not session_uuid:
log.error('ws 连接失败:授权失败,请检查') log.error('WebSocket 连接失败:授权失败,请检查')
return False return False
# 免授权直连 # 免授权直连
@@ -49,7 +50,7 @@ async def connect(sid, environ, auth):
try: try:
await jwt_authentication(token) await jwt_authentication(token)
except Exception as e: except Exception as e:
log.info(f'ws 连接失败:{e}') log.info(f'WebSocket 连接失败:{str(e)}')
return False return False
await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid) await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid)
@@ -57,6 +58,6 @@ async def connect(sid, environ, auth):
@sio.event @sio.event
async def disconnect(sid): async def disconnect(sid: str) -> None:
"""当客户端断开连接时触发""" """处理 WebSocket 断开连接事件"""
await redis_client.spop(settings.TOKEN_ONLINE_REDIS_PREFIX) await redis_client.spop(settings.TOKEN_ONLINE_REDIS_PREFIX)
+89 -87
View File
@@ -6,36 +6,39 @@ from typing import Any, Literal
from pydantic import model_validator from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from backend.core.path_conf import BasePath from backend.core.path_conf import BASE_PATH
class Settings(BaseSettings): class Settings(BaseSettings):
"""Global Settings""" """全局配置"""
model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore') model_config = SettingsConfigDict(
env_file=f'{BASE_PATH}/.env',
env_file_encoding='utf-8',
extra='ignore',
case_sensitive=True,
)
# Env Config # .env 环境
ENVIRONMENT: Literal['dev', 'pro'] ENVIRONMENT: Literal['dev', 'pro']
# Env Database Type # .env 数据库
DATABASE_TYPE: Literal['mysql', 'postgresql'] DATABASE_TYPE: Literal['mysql', 'postgresql']
# Env Database
DATABASE_HOST: str DATABASE_HOST: str
DATABASE_PORT: int DATABASE_PORT: int
DATABASE_USER: str DATABASE_USER: str
DATABASE_PASSWORD: str DATABASE_PASSWORD: str
# Env Redis # .env Redis
REDIS_HOST: str REDIS_HOST: str
REDIS_PORT: int REDIS_PORT: int
REDIS_PASSWORD: str REDIS_PASSWORD: str
REDIS_DATABASE: int REDIS_DATABASE: int
# Env Token # .env Token
TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32) TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32)
# Env Opera Log # .env 操作日志加密密钥
OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str
# FastAPI # FastAPI
@@ -48,14 +51,7 @@ class Settings(BaseSettings):
FASTAPI_OPENAPI_URL: str | None = '/openapi' FASTAPI_OPENAPI_URL: str | None = '/openapi'
FASTAPI_STATIC_FILES: bool = True FASTAPI_STATIC_FILES: bool = True
# Upload # 数据库
UPLOAD_READ_SIZE: int = 1024 # 上传文件时分片读取大小
UPLOAD_IMAGE_EXT_INCLUDE: list[str] = ['jpg', 'jpeg', 'png', 'gif', 'webp']
UPLOAD_IMAGE_SIZE_MAX: int = 1024 * 1024 * 5
UPLOAD_VIDEO_EXT_INCLUDE: list[str] = ['mp4', 'mov', 'avi', 'flv']
UPLOAD_VIDEO_SIZE_MAX: int = 1024 * 1024 * 20
# Database
DATABASE_ECHO: bool = False DATABASE_ECHO: bool = False
DATABASE_POOL_ECHO: bool = False DATABASE_POOL_ECHO: bool = False
DATABASE_SCHEMA: str = 'fba' DATABASE_SCHEMA: str = 'fba'
@@ -64,24 +60,21 @@ class Settings(BaseSettings):
# Redis # Redis
REDIS_TIMEOUT: int = 5 REDIS_TIMEOUT: int = 5
# Socketio
WS_NO_AUTH_MARKER: str = 'internal'
# Token # Token
TOKEN_ALGORITHM: str = 'HS256' # 算法 TOKEN_ALGORITHM: str = 'HS256'
TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1 # 过期时间,单位:秒 TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 # 1 天
TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # refresh token 过期时间,单位:秒 TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
TOKEN_REDIS_PREFIX: str = 'fba:token' TOKEN_REDIS_PREFIX: str = 'fba:token'
TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'fba:token_extra_info' TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'fba:token_extra_info'
TOKEN_ONLINE_REDIS_PREFIX: str = 'fba:token_online' TOKEN_ONLINE_REDIS_PREFIX: str = 'fba:token_online'
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',
] ]
# JWT # JWT
JWT_USER_REDIS_PREFIX: str = 'fba:user' JWT_USER_REDIS_PREFIX: str = 'fba:user'
JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# RBAC # RBAC
RBAC_ROLE_MENU_MODE: bool = False RBAC_ROLE_MENU_MODE: bool = False
@@ -90,13 +83,70 @@ class Settings(BaseSettings):
'sys:monitor:server', 'sys:monitor:server',
] ]
# Cookies # Cookie
COOKIE_REFRESH_TOKEN_KEY: str = 'fba_refresh_token' COOKIE_REFRESH_TOKEN_KEY: str = 'fba_refresh_token'
COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = TOKEN_REFRESH_EXPIRE_SECONDS COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# Log # 数据权限配置
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'Api': 'backend.plugin.casbin.model.Api',
}
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id',
'sort',
'created_time',
'updated_time',
]
# Socket.IO
WS_NO_AUTH_MARKER: str = 'internal'
# CORS
CORS_ALLOWED_ORIGINS: list[str] = [ # 末尾不带斜杠
'http://127.0.0.1:8000',
'http://localhost:5173',
]
CORS_EXPOSE_HEADERS: list[str] = [
'X-Request-ID',
]
# 中间件配置
MIDDLEWARE_CORS: bool = True
MIDDLEWARE_ACCESS: bool = True
# 请求限制配置
REQUEST_LIMITER_REDIS_PREFIX: str = 'fba:limiter'
# 时间配置
DATETIME_TIMEZONE: str = 'Asia/Shanghai'
DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S'
# 文件上传
UPLOAD_READ_SIZE: int = 1024
UPLOAD_IMAGE_EXT_INCLUDE: list[str] = ['jpg', 'jpeg', 'png', 'gif', 'webp']
UPLOAD_IMAGE_SIZE_MAX: int = 5 * 1024 * 1024 # 5 MB
UPLOAD_VIDEO_EXT_INCLUDE: list[str] = ['mp4', 'mov', 'avi', 'flv']
UPLOAD_VIDEO_SIZE_MAX: int = 20 * 1024 * 1024 # 20 MB
# 演示模式配置
DEMO_MODE: bool = False
DEMO_MODE_EXCLUDE: set[tuple[str, str]] = {
('POST', f'{FASTAPI_API_V1_PATH}/auth/login'),
('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'),
('GET', f'{FASTAPI_API_V1_PATH}/auth/captcha'),
}
# IP 定位配置
IP_LOCATION_PARSE: Literal['online', 'offline', 'false'] = 'offline'
IP_LOCATION_REDIS_PREFIX: str = 'fba:ip:location'
IP_LOCATION_EXPIRE_SECONDS: int = 60 * 60 * 24 # 1 天
# 追踪 ID
TRACE_ID_REQUEST_HEADER_KEY: str = 'X-Request-ID'
# 日志
LOG_CID_DEFAULT_VALUE: str = '-' LOG_CID_DEFAULT_VALUE: str = '-'
LOG_CID_UUID_LENGTH: int = 32 # must <= 32 LOG_CID_UUID_LENGTH: int = 32 # 日志 correlation_id 长度,必须小于等于 32
LOG_STD_LEVEL: str = 'INFO' LOG_STD_LEVEL: str = 'INFO'
LOG_ACCESS_FILE_LEVEL: str = 'INFO' LOG_ACCESS_FILE_LEVEL: str = 'INFO'
LOG_ERROR_FILE_LEVEL: str = 'ERROR' LOG_ERROR_FILE_LEVEL: str = 'ERROR'
@@ -111,48 +161,12 @@ class Settings(BaseSettings):
LOG_ACCESS_FILENAME: str = 'fba_access.log' LOG_ACCESS_FILENAME: str = 'fba_access.log'
LOG_ERROR_FILENAME: str = 'fba_error.log' LOG_ERROR_FILENAME: str = 'fba_error.log'
# Middleware # 操作日志
MIDDLEWARE_CORS: bool = True
MIDDLEWARE_ACCESS: bool = True
# Trace ID
TRACE_ID_REQUEST_HEADER_KEY: str = 'X-Request-ID'
# CORS
CORS_ALLOWED_ORIGINS: list[str] = [
'http://127.0.0.1:8000',
'http://localhost:5173', # 前端地址,末尾不要带 '/'
]
CORS_EXPOSE_HEADERS: list[str] = [
TRACE_ID_REQUEST_HEADER_KEY,
]
# DateTime
DATETIME_TIMEZONE: str = 'Asia/Shanghai'
DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S'
# Request limiter
REQUEST_LIMITER_REDIS_PREFIX: str = 'fba:limiter'
# Demo mode (Only GET, OPTIONS requests are allowed)
DEMO_MODE: bool = False
DEMO_MODE_EXCLUDE: set[tuple[str, str]] = {
('POST', f'{FASTAPI_API_V1_PATH}/auth/login'),
('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'),
('GET', f'{FASTAPI_API_V1_PATH}/auth/captcha'),
}
# Ip location
IP_LOCATION_PARSE: Literal['online', 'offline', 'false'] = 'offline'
IP_LOCATION_REDIS_PREFIX: str = 'fba:ip:location'
IP_LOCATION_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1 # 过期时间,单位:秒
# Opera log
OPERA_LOG_PATH_EXCLUDE: list[str] = [ OPERA_LOG_PATH_EXCLUDE: list[str] = [
'/favicon.ico', '/favicon.ico',
FASTAPI_DOCS_URL, '/docs',
FASTAPI_REDOC_URL, '/redoc',
FASTAPI_OPENAPI_URL, '/openapi',
f'{FASTAPI_API_V1_PATH}/auth/login/swagger', f'{FASTAPI_API_V1_PATH}/auth/login/swagger',
f'{FASTAPI_API_V1_PATH}/oauth2/github/callback', f'{FASTAPI_API_V1_PATH}/oauth2/github/callback',
f'{FASTAPI_API_V1_PATH}/oauth2/linux-do/callback', f'{FASTAPI_API_V1_PATH}/oauth2/linux-do/callback',
@@ -165,27 +179,15 @@ class Settings(BaseSettings):
'confirm_password', 'confirm_password',
] ]
# Data permission # 插件配置
DATA_PERMISSION_MODELS: dict[
str, str
] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义(它应该只用于前台数据,这里只是为了演示)
'Api': 'backend.app.admin.model.Api',
}
DATA_PERMISSION_COLUMN_EXCLUDE: list[str] = [ # 排除允许进行数据过滤的 SQLA 模型列
'id',
'sort',
'created_time',
'updated_time',
]
# 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/'
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def check_env(cls, values: Any) -> Any: def check_env(cls, values: Any) -> Any:
if values['ENVIRONMENT'] == 'pro': """生产环境下禁用 OpenAPI 文档和静态文件服务"""
if values.get('ENVIRONMENT') == 'pro':
values['FASTAPI_OPENAPI_URL'] = None values['FASTAPI_OPENAPI_URL'] = None
values['FASTAPI_STATIC_FILES'] = False values['FASTAPI_STATIC_FILES'] = False
return values return values
@@ -193,9 +195,9 @@ class Settings(BaseSettings):
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
"""获取全局配置""" """获取全局配置单例"""
return Settings() return Settings()
# 创建配置实例 # 创建全局配置实例
settings = get_settings() settings = get_settings()
+10 -16
View File
@@ -1,30 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os
from pathlib import Path from pathlib import Path
# 获取项目根目录 # 项目根目录
# 或使用绝对路径,指到backend目录为止,例如windowsBasePath = D:\git_project\fastapi_mysql\backend BASE_PATH = Path(__file__).resolve().parent.parent
BasePath = Path(__file__).resolve().parent.parent
# alembic 迁移文件存放路径 # alembic 迁移文件存放路径
ALEMBIC_VERSION_DIR = os.path.join(BasePath, 'alembic', 'versions') ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions'
# 日志文件路径 # 日志文件路径
LOG_DIR = os.path.join(BasePath, 'log') LOG_DIR = BASE_PATH / 'log'
# 离线 IP 数据库路径
IP2REGION_XDB = os.path.join(BasePath, 'static', 'ip2region.xdb')
# 静态资源目录 # 静态资源目录
STATIC_DIR = os.path.join(BasePath, 'static') STATIC_DIR = BASE_PATH / 'static'
# 上传文件目录 # 上传文件目录
UPLOAD_DIR = os.path.join(BasePath, 'static', 'upload') UPLOAD_DIR = STATIC_DIR / 'upload'
# jinja2 模版文件路径
JINJA2_TEMPLATE_DIR = os.path.join(BasePath, 'templates')
# 插件目录 # 插件目录
PLUGIN_DIR = os.path.join(BasePath, 'plugin') PLUGIN_DIR = BASE_PATH / 'plugin'
# 离线 IP 数据库路径
IP2REGION_XDB = STATIC_DIR / 'ip2region.xdb'

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