Compare commits

...
295 Commits
Author SHA1 Message Date
Wu Clan b775dd3a16 sync 2026-08-23 12:16:43 +08:00
Wu Clan 6149d0472f Update the changelog for v1.15.1 (#1236) 2026-08-16 16:47:36 +08:00
Wu Clan a0b3f022fe Update the version number to 1.15.1 (#1235) 2026-08-16 16:25:56 +08:00
Wu Clan 69001f2745 Bump ruff version for RUF201 rule (#1234) 2026-08-16 16:22:22 +08:00
Wu Clan ec6e1cdfd1 Optimize config of multiple database sources (#1233) 2026-08-16 15:55:12 +08:00
Wu Clan 7568275126 Add multiple database data source config (#1232)
* Add multiple database data source config

* Fix comments

* Fix deploy env
2026-08-14 17:44:45 +08:00
Wu Clan 67a6a58e57 Bump dependencies and pre-commits (#1231) 2026-08-13 11:09:42 +08:00
Wu Clan 8e5c379853 Bump dependencies and pre-commits (#1228)
* Bump dependencies and pre-commits

* Fix lint
2026-07-21 15:19:20 +08:00
Wu Clan 2dffe8f6a8 Update the changelog for v1.15.0 (#1226) 2026-07-10 18:01:52 +08:00
Wu Clan c802f67a5a Update the version number to 1.15.0 (#1225) 2026-07-10 17:58:49 +08:00
Wu Clan 1916261795 Update plugin config format validation (#1224) 2026-07-10 17:55:24 +08:00
Wu Clan 325016adc8 Update xdb database file to release (#1223) 2026-07-10 09:53:36 +08:00
Wu Clan 09fd380c3a Refactor plugin dynamic config loading (#1222) 2026-07-09 17:20:01 +08:00
IAseven e6292747bd Fix duplicate log reporting in OTEL (#1221) 2026-07-08 15:57:43 +08:00
Wu Clan a96b2cfad1 Update document link addresses in the CLI (#1220) 2026-07-08 13:55:22 +08:00
Wu Clan a0a1bde88c Update capability plugin config validation (#1219) 2026-07-08 11:48:51 +08:00
9251a5d5fd Add support for capability plugins (#1217)
* feat(plugin): support capability plugins

Add capability plugin metadata and parsing so plugins without app/api sections can participate in dependency ordering and hooks without being routed as app plugins.

Also rebuild missing plugin status cache from the normalized plugin config when a route dependency checks plugin state.

* refactor code

---------

Co-authored-by: linwu@onemt.com.cn <linwu@onemt.com.cn>
Co-authored-by: Wu Clan <jianhengwu0407@gmail.com>
2026-07-08 11:19:20 +08:00
Wu Clan e3d2c68592 Bump dependencies and pre-commits (#1218) 2026-07-06 14:46:29 +08:00
Wu Clan 4d6970c6cc Add is superuser protocol to ctx (#1216) 2026-07-03 13:40:58 +08:00
Wu Clan d4f78d6448 Fix JWT auth dependency error propagation (#1215) 2026-07-02 16:20:26 +08:00
Wu Clan efec3308df Add underlying security config for JWT (#1214) 2026-07-02 14:42:02 +08:00
Wu Clan 0441907b00 Fix redis rate limiter bucket routing (#1212)
* Fix redis rate limiter bucket routing

* Add rate limiter function docstrings

* Optimize Redis rate limiter time source
2026-06-29 14:06:57 +08:00
Wu Clan 08ee3878c1 Refactor cache key and prefix APIs (#1210) 2026-06-25 14:19:37 +08:00
Wu Clan c7e686588e Add AI dynamic configuration menu (#1209) 2026-06-24 11:42:53 +08:00
Wu Clan 495046c978 Fix redis pubsub socket timeout (#1206) 2026-06-20 23:58:29 +08:00
Wu Clan ffaa1282cc Bump dependencies and pre-commits (#1205) 2026-06-19 22:47:33 +08:00
Wu Clan 15dda9ab6e Refactor AI response regeneration 2026-06-16 15:10:38 +08:00
Wu Clan 814068a9e6 Add plugin deps command to the CLI (#1200)
* Add plugin deps command to the CLI

* Add dependency sync command to the CLI

* Fix plugin dependency index fallback
2026-06-16 11:25:53 +08:00
IAseven 39cdfdfb00 Fix user salt column LargeBinary length (#1197) 2026-06-03 17:11:32 +08:00
Wu Clan 4b730099cd Optimize plugin core and add otel hooks (#1196) 2026-06-01 18:15:59 +08:00
Wu Clan 974972b490 Add celery explicit idempotent protection (#1195) 2026-06-01 18:15:31 +08:00
Wu Clan c9b4678148 Update the changelog for v1.14.0 (#1194) 2026-05-30 16:04:53 +08:00
Wu Clan d20bc91465 Update the version number to 1.14.0 (#1193) 2026-05-30 16:02:03 +08:00
Wu Clan bc8d1ab9e3 Fix task scheduler unpack fields (#1192) 2026-05-30 15:54:12 +08:00
Wu Clan a5e9a0e805 Refactor physical deletion to logical deletion (#1191)
* Refactor physical deletion to logical deletion

* Update the usage of unique indexes

* Update scheduler update and delete

* Fix lint

* Add some missing logic

* Optimize codegen get by id
2026-05-30 15:02:00 +08:00
Wu Clan 1897b3198e Optimize the usage of setex for redis (#1190) 2026-05-29 11:14:47 +08:00
Wu Clan 6b9c7d81de Bump dependencies and pre-commits (#1189) 2026-05-29 11:00:32 +08:00
Wu Clan e9b83d3c93 Optimize Grafana observability metrics and config (#1188)
* Optimize Grafana observability metrics and config

* Update docker images
2026-05-29 09:41:50 +08:00
Wu Clan 0f0e94c9b1 Add opera log task cancel to lifespan (#1187) 2026-05-28 11:44:09 +08:00
Wu Clan ba0f30cde1 Optimize operation log truncation handling (#1186) 2026-05-28 11:13:37 +08:00
Wu Clan 9f3673d812 Optimize user session logging and deletion (#1185) 2026-05-28 10:25:43 +08:00
Wu Clan ef9a6b554c Fix cache invalidation decorator usage (#1182) 2026-05-26 16:14:47 +08:00
Wu Clan dbfdc6762e Optimize SQL script execution in the CLI (#1181) 2026-05-25 11:06:06 +08:00
Wu Clan 2e86d79150 Optimize some global variable definitions (#1178) 2026-05-15 11:55:22 +08:00
Wu Clan 5352f98c13 Optimize database operations within loops (#1177)
* Optimize database operations within loops

* Optimize opera log
2026-05-14 11:38:38 +08:00
Wu Clan e32b4232c5 Update xdb dependency and database file (#1171) 2026-05-07 13:42:31 +08:00
Wu Clan 0731f38702 Update the changelog for v1.13.4 (#1170) 2026-04-28 18:07:19 +08:00
Wu Clan 7ebcdda26f Update the version number to 1.13.4 (#1169) 2026-04-28 18:03:05 +08:00
Wu Clan f32f7e56a2 Bump dependencies and pre-commits (#1168) 2026-04-28 17:57:14 +08:00
Wu Clan 330639d975 Fix typing warnings for return methods (#1167) 2026-04-28 16:42:23 +08:00
Wu Clan 0fa5468483 Fix multi level cache storage logic (#1166) 2026-04-27 17:54:09 +08:00
Wu Clan bfe4aa91ce Add uninstall plugin operation checks (#1165)
* Add uninstall plugin operation checks

* Fix lint
2026-04-27 17:36:00 +08:00
Wu Clan 5d85bc98d2 Optimize the core code ordering of plugins (#1164) 2026-04-27 09:51:18 +08:00
Wu Clan 8f90f764b6 Fix the add plugin CLI command config (#1163) 2026-04-26 23:18:40 +08:00
Wu Clan 49d12f7825 Add the snowflake ID enabled config (#1162) 2026-04-26 21:56:28 +08:00
Wu Clan b60c9ab17c Fix Grafana tempo and observability config (#1161)
* Add database and queue observability

* Update prometheus

* Delete the wrong changes

* Fix Grafana tempo and observability config
2026-04-26 13:09:47 +08:00
Wu Clan 576b2fc5d0 Add database and queue observability (#1160)
* Add database and queue observability

* Update prometheus

* Delete the wrong changes
2026-04-26 13:07:03 +08:00
Wu Clan 35a368ae04 Update Grafana dashboard and datasource config (#1159) 2026-04-21 18:02:37 +08:00
Wu Clan 776482c5d6 Update plugin README content format (#1158)
* Update plugin README content format

* Fix lint
2026-04-19 21:36:57 +08:00
Toby WongandWu Clan 92f182cd09 Add plugin depends on and lifecycle ordering (#1153)
* feat: add priority support to LifespanManager.register

* feat: support lifespan stages and plugin dependencies

* Optimize implementations

* Fix dependency resolution

* Fix the depends on type

---------

Co-authored-by: Wu Clan <jianhengwu0407@gmail.com>
2026-04-17 16:54:48 +08:00
Wu Clan 0428a2b07d Bump dependencies and pre-commits (#1156) 2026-04-14 23:58:51 +08:00
Wu Clan 9f193c7e7c Fix mysql enum data type mismatch (#1155) 2026-04-14 23:03:15 +08:00
Wu Clan 4e0ca6a214 Remove cleanup operations after plugin install (#1154)
* Remove cleanup operations after plugin install

* Fix lint
2026-04-13 15:09:45 +08:00
IAseven 45842d917a Fix app pytest client fixture scope (#1152)
* 🐞 fix:修复测试模块client scope=module时异常

* 🌈 style:

* 🌈 style:
2026-04-12 23:43:37 +08:00
Wu Clan 8719c63424 Update the architecture description in README (#1150) 2026-04-08 21:53:10 +08:00
Wu Clan d71db7d58d Update the changelog for v1.13.3 (#1149) 2026-04-08 21:05:52 +08:00
Wu Clan 02b5e670cc Update the version number to 1.13.3 (#1148) 2026-04-08 20:45:21 +08:00
Wu Clan 41f53d7225 Bump dependencies and pre-commits (#1147) 2026-04-08 20:44:59 +08:00
YuWorm b71179cea9 Add plugin system hook function mechanism (#1142)
* Enhance plugin system with lifespan registry, setup hooks, and patching utilities

* fix: remove registrar.py unused-import

* Optimize implementations

* Update function docs

* update replace_middleware func docs

* Recover deleted comments
2026-04-08 10:35:02 +08:00
Wu Clan 41a64bf64c Add superpowers to gitignore file (#1146) 2026-04-08 10:34:06 +08:00
Wu Clan afacc84da3 Fix custom email model return type (#1145) 2026-04-08 10:33:46 +08:00
Wu Clan 3bbe460beb Update custom reload filter call rules (#1144) 2026-04-08 09:10:19 +08:00
Wu Clan 131bfcf063 Add the installation frontend plugin CLI (#1141) 2026-04-03 09:51:19 +08:00
Wu Clan 283d762141 Refactor plugin dependency cache and check (#1140) 2026-04-02 17:00:20 +08:00
Wu Clan bd9737cc6e Fix the return of registered task results (#1139) 2026-04-02 13:56:29 +08:00
Wu Clan 6d381dba74 Fix current superuser permission validation (#1138) 2026-04-02 12:38:21 +08:00
Wu Clan 7e6856a5e0 Fix plugin dependencies for redis instance (#1133) 2026-03-30 14:37:31 +08:00
Wu Clan c96181bd08 Update plugin config and dependency cache (#1130)
* Update plugin config and dependency cache

* Fix deps check
2026-03-27 23:50:58 +08:00
Wu Clan 7a934fee32 Update the plugin requirements check (#1129) 2026-03-27 22:42:30 +08:00
Wu Clan 5bec0e083b Add custom cursor pagination support (#1128)
* Add custom cursor pagination support

* Update fastapi pagination dependency
2026-03-25 20:46:22 +08:00
Wu Clan a97dab1716 Update plugins and monitoring permissions (#1126) 2026-03-24 15:59:22 +08:00
Wu Clan 5ff7f4bc69 Fix code generation menu sql scripts (#1127) 2026-03-24 15:59:06 +08:00
Wu Clan 94f643c587 Fix the logic related to the status field (#1125) 2026-03-24 11:43:09 +08:00
Wu Clan c98ea57a94 Update the changelog for v1.13.2 (#1124) 2026-03-23 16:58:54 +08:00
Wu Clan a58ca7f156 Update the version number to 1.13.2 (#1123) 2026-03-23 16:56:42 +08:00
Wu Clan c08f64ae75 Add sql scripts for OAuth2 plugin (#1121)
* Add sql scripts for OAuth2 plugin

* Fix lint
2026-03-23 16:46:58 +08:00
Wu Clan 48352563ef Update plugin config and sql validate (#1122)
* Update plugin config and sql validate

* Fix lint
2026-03-23 16:44:44 +08:00
Wu Clan f87d8f43c8 Fix zip plugin installation and unzip (#1120)
* Fix zip plugin installation and unzip

* FIx lint
2026-03-22 20:49:53 +08:00
Wu Clan 568e3ca66b Fix plugin status change cache missing (#1118) 2026-03-21 23:34:40 +08:00
Wu Clan a9acc31a78 Fix online api user id acquisition (#1116) 2026-03-19 12:12:20 +08:00
Wu Clan 2866fc45b1 Fix typos in code and comments (#1115) 2026-03-19 11:50:43 +08:00
Wu Clan dcb68698e5 Fix user menu permission code update (#1114) 2026-03-19 11:50:20 +08:00
Wu Clan 06f9751c84 Fix rbac permission checking logic (#1113) 2026-03-19 11:50:04 +08:00
Wu Clan 23975442d8 Update plugin dynamic import check (#1112)
* Update plugin dynamic import check

* Update cli dynamic import
2026-03-19 11:49:33 +08:00
Wu Clan 577a13b271 Update refresh token geo-checksum response (#1111) 2026-03-19 11:48:42 +08:00
Wu Clan 05bb98dada Update user id context setting location (#1110)
* Update user id context setting location

* Update jwt middleware

* Fix lint
2026-03-19 11:48:11 +08:00
Wu Clan 4abe09dbc3 Replace the uvicorn runner with granian (#1105) 2026-03-09 21:59:00 +08:00
Wu Clan 2bedeef26c Fix log type check and default formatter (#1104) 2026-03-09 21:58:27 +08:00
Wu Clan 8efcc394cc Update the changelog for v1.13.1 (#1103) 2026-03-08 18:50:31 +08:00
Wu Clan 8fbd2c1ede Update the version number to 1.13.1 (#1102) 2026-03-08 18:48:26 +08:00
Wu Clan 19de14ffa6 Bump dependencies and pre-commits (#1100) 2026-03-08 13:12:56 +08:00
Wu Clan 2876f57ea4 Update utc zoneinfo to datetime timezone (#1099) 2026-03-07 14:23:10 +08:00
Wu Clan e9ed1292f0 Update project name symbol in multiple files (#1097) 2026-03-05 18:37:16 +08:00
Wu Clan 62ece2f289 Fix menu SQL in mysql SQL scripts (#1096) 2026-03-05 18:36:55 +08:00
Wu Clan 0bd41a9e67 Optimize the CLI command console output style (#1094)
* Optimize the CLI command console output style

* Update output functions

* Update destroy sql style
2026-03-05 18:36:44 +08:00
SmallGarbageandWu Clan e8ac9e848c Update the upload file type validation logic (#1091)
* Refactor file type validation logic

Refactor file type checks to allow valid formats and raise errors for unsupported types.

* Fix lint

---------

Co-authored-by: Wu Clan <jianhengwu0407@gmail.com>
2026-02-28 22:23:23 +08:00
Wu Clan 9698dde9c2 Add destroy SQL scripts for multiple plugins (#1093) 2026-02-28 22:23:07 +08:00
Wu Clan 39c3366e0a Refactor the menu SQL definition in SQL scripts (#1092)
* Refactor the menu SQL definition in SQL scripts

* Fix know issues
2026-02-28 20:08:27 +08:00
Wu Clan 6432a66732 Add remove plugin and formatting code CLI (#1088) 2026-02-25 21:22:49 +08:00
Wu Clan 1aa448796b Fix uninstall plugin requirements command (#1087) 2026-02-25 18:07:11 +08:00
Wu Clan db8fff0431 Fix query sorting based on model sort column (#1083) 2026-02-25 11:21:08 +08:00
Wu Clan 28113a4d52 Remove the sponsorship from the readme (#1085) 2026-02-24 21:11:48 +08:00
Wu Clan d843dfa1c7 Update SQL scripts related to data permissions (#1084) 2026-02-24 14:15:55 +08:00
Wu Clan 9e817db278 Add data permission rule value template variable (#1081)
* Add data permission rule value template variable

* Improved models and columns
2026-02-23 18:12:02 +08:00
Wu Clan 0dfe74d1a3 Add redis client native OTEL observability (#1082) 2026-02-23 15:15:59 +08:00
Wu Clan eb9399e14f Fix task issues and update crontab expressions (#1080) 2026-02-16 14:37:56 +08:00
Wu Clan 0c9eafbbb9 Update the changelog for v1.13.0 (#1078) 2026-02-14 13:13:27 +08:00
Wu Clan b412dceb9e Update the version number to 1.13.0 (#1079)
* Update the version number to 1.13.0

* Update changelog
2026-02-14 12:48:53 +08:00
Wu Clan 1de4201b52 Fix missing parent class init in base exception (#1077) 2026-02-13 15:08:58 +08:00
Wu Clan bcdaf3f3c9 Bump dependencies and pre-commits (#1076) 2026-02-13 14:44:57 +08:00
Wu Clan 565800d8cf Fix dynamic config cache serialization (#1075)
* Fix dynamic config cache serialization

* Fix lint
2026-02-13 14:30:38 +08:00
Wu Clan 966779bcd7 Refactor the interface rate limiter implementation (#1072)
* Refactor the interface rate limiter implementation

* Update deps
2026-02-11 17:27:46 +08:00
Wu Clan 3f06baffed Add cache key chaining syntax support (#1069) 2026-02-07 14:34:16 +08:00
Wu Clan 6de3eee4e0 Add version check and update workflows (#1068)
* Add version check and update workflows

* Fix lint
2026-02-07 13:14:45 +08:00
Wu Clan e227eb1768 Add multi level cache for dynamic config (#1067) 2026-02-06 15:19:05 +08:00
MicLon b6875a3c37 Fix captcha and user validation order logic error (#1065) 2026-02-06 14:08:07 +08:00
yzbf c521d2ef36 Add log mount to docker compose (#1063)
* Expose container logs via Docker Compose

* Simplify Docker logs mount

* Update comments
2026-02-06 14:02:40 +08:00
Wu Clan 4d457b2215 Fix multi level cache key build and usage (#1066)
* Fix multi level cache key build and usage

* Update build cache key
2026-02-06 13:42:13 +08:00
Wu Clan 95aebe1ceb Add tombi and plugin JSON schema config (#1062)
* Add tombi and plugin JSON schema config

* Add tombi lint
2026-02-05 14:47:20 +08:00
Wu Clan f5af411339 Add tombi toml and bump pre-commits (#1061) 2026-02-04 23:37:38 +08:00
Wu Clan d1fd5b6694 Update alembic and ruff config to pyproject.toml (#1060)
* Update alembic and ruff config to pyproject.toml

* Fix lint
2026-02-04 23:30:05 +08:00
Wu Clan ab1f60120a Optimize the serialization of join query results (#1058)
* Optimize the serialization of join query results

* Update some naming
2026-02-04 20:20:20 +08:00
SoulEater 220de51aad Fix relational insertion error for empty lists (#1056) 2026-02-03 21:29:53 +08:00
Wu Clan 646a0ec2fc Add granian and celery metrics collection (#1057)
* Add granian and celery metrics collection

* Update dashboards

* Add asyncio instrument

* Fix opera log finally
2026-02-03 20:44:05 +08:00
Wu Clan 6f1c27786d Add multi level caching and optimize caching (#1054)
* Add multi level caching and optimize caching

* optimize current caching

* improved decorators

* improved local

* add pub/sub

* fix serialize

* Improve pub and sub

* Add comment

* Fix lru_cache maxsize

* Fix lint

* Fix config warmup
2026-02-02 17:18:49 +08:00
Wu Clan 093788acea Update redis and server health monitor data (#1051)
* Update redis and server health monitor data

* Update uptime
2026-01-30 16:45:22 +08:00
Wu Clan 4bab3c2cb0 Update task path resolution and docker variables (#1050) 2026-01-30 10:55:39 +08:00
Wu Clan b82f9e8c1c Add the alembic related operation CLI (#1049) 2026-01-29 14:50:14 +08:00
Wu Clan 89f8fe141e Update plugin and code generation subprocess output (#1048) 2026-01-29 13:02:04 +08:00
Wu Clan ac19f8480f Update the plugin git url pattern (#1047) 2026-01-27 17:06:43 +08:00
Wu Clan d397a0d985 Fix code generation app route injection (#1042) 2026-01-26 21:27:30 +08:00
Wu Clan eb27b5bfed Add data validator for plugin config (#1041) 2026-01-26 21:17:18 +08:00
Wu Clan 08674ab95f Update plugins and adapt to new features (#1040)
* Update plugins and adapt to new features

* Update plugin versions
2026-01-25 20:23:08 +08:00
Wu Clan fbdc3f2dc5 Add code generation preview mode CLI command (#1036) 2026-01-25 15:17:04 +08:00
Wu Clan 19d8a2964f Remove deprecated parameter for code generation business (#1035)
* Remove deprecated parameter for code generation business

* Update doc_comment

* Fix doc_comment
2026-01-25 15:16:07 +08:00
Wu Clan a496932138 Add injection app routes to code generation (#1034)
* Add injection app routes to code generation

* Update get table sqls

* Update get table sqls

* Fix lint

* Fix sql scripts syntax
2026-01-25 15:14:01 +08:00
Wu Clan a6da0bfd41 Refactor code generation and plugin hot reloading (#1032)
* Refactor code generation and plugin hot reloading

* Improve implementation

* Update plugin config loader
2026-01-23 17:08:51 +08:00
Wu Clan ced2b8d269 Allow to add config in plugin toml (#1033) 2026-01-23 16:42:24 +08:00
Wu Clan f2b246089d Add router jinja template for code generation (#1031) 2026-01-23 10:39:47 +08:00
Wu Clan 0aa63ed6e6 Fix custom filename in code generation (#1030) 2026-01-22 16:33:37 +08:00
Wu Clan c8ebd1e9f6 Bump dependencies and pre-commits (#1029) 2026-01-22 15:55:33 +08:00
Wu Clan 68d962f2f8 Update the i18n file storage directory (#1027) 2026-01-21 12:03:40 +08:00
Wu Clan 1a6aba6105 Optimize code generation data processing (#1020)
* Optimize code generation data processing

* Add SQL script generation

* Update code gen table scripts

* Update types

* Fix model jinja and type conversion
2026-01-20 18:27:42 +08:00
Wu Clan b2785dd46f Fix snowflake type primary key field serializer (#1024) 2026-01-18 23:43:33 +08:00
Wu Clan dc73f09c55 Optimize i18n language file directory structure (#1023) 2026-01-18 16:16:37 +08:00
Wu Clan b4d984cb4e Optimize dynamic config loading implementation (#1022) 2026-01-18 16:01:59 +08:00
Wu Clan 4d19cd2f5a Rename locale to locales to avoid library conflict (#1021) 2026-01-18 15:01:43 +08:00
Wu Clan d9d0c839ed Fix database engine in auto init CLI command (#1018) 2026-01-16 13:41:14 +08:00
Wu Clan ca51c2f8c5 Fix install plugin dependencies in docker container (#1017) 2026-01-16 12:40:56 +08:00
Wu Clan 88b46c1b72 Fix database calls in init CLI command (#1016) 2026-01-15 20:03:53 +08:00
Wu Clan 383620c899 Update the database and Redis for easier scaling (#1015)
* Update the database and Redis for easier scaling

* Restore plugin redis variable naming
2026-01-15 16:54:43 +08:00
Wu Clan f876162456 Update the JWT for easier scaling (#1011)
* Update the JWT for easier scaling

* Fix comment
2026-01-14 23:52:29 +08:00
Wu Clan 362a559236 Update the changelog for v1.12.3 (#1009) 2026-01-13 19:50:54 +08:00
Wu Clan 8ae1a43581 Update i18n language storage and loading (#1008) 2026-01-13 19:10:41 +08:00
Wu Clan ee849f0854 Update the plugin dependency install method (#1007) 2026-01-13 16:14:41 +08:00
Wu Clan 5c9a27cc16 Update nickname generation when create user (#1004)
* Update nickname generation when create user

* Fix lint

* Update code style
2026-01-12 19:03:02 +08:00
Wu Clan 1b68854b84 Update code generation part file naming (#1002) 2026-01-11 18:03:20 +08:00
Wu Clan b73585ebd2 Optimize definitions of multiple utility functions (#1001)
* Optimize definitions of multiple utility functions

* Update plugin tools
2026-01-11 16:57:52 +08:00
Wu Clan 28a6228556 Update redis and server monitor implementations (#1000)
* Update redis and server monitor implementations

* Fix server disk information
2026-01-11 13:21:37 +08:00
Wu Clan dfce2ca094 Remove the opera log desensitization asynchronous (#999) 2026-01-10 18:30:16 +08:00
shj366 326bdf9a17 Fix opera log non-json data overload (#998)
* fix: skip non-json body logging for multipart uploads

* Update truncation implementation
2026-01-10 18:23:02 +08:00
Wu Clan cbe4e5ebe0 Update login log request header column length (#996) 2026-01-08 12:23:37 +08:00
Wu Clan 2d666e375f Update the changelog for v1.12.2 (#995) 2026-01-07 12:23:47 +08:00
Wu Clan ff40c41549 Remove Linux Do OAuth2 login (#994) 2026-01-07 12:21:21 +08:00
Wu Clan 7619670521 Fix the key of the refresh token removed (#993) 2026-01-07 12:09:10 +08:00
Wu Clan 85ba942f35 Fix SQL scripts error in config plugin (#991) 2026-01-06 14:30:49 +08:00
Wu Clan 88f3173f8f Fix operation log queue status management (#989)
* Fix operation log queue status management

* Fix lint
2026-01-06 10:53:23 +08:00
Wu Clan fd87dfb25a Remove invalid configs of operation log (#988) 2026-01-05 14:09:24 +08:00
Wu Clan ad7f725e4c Simplify the desensitization of operation log data (#987) 2026-01-05 12:16:51 +08:00
Wu Clan 2ef8810376 Fix environment variable file auto init (#985) 2026-01-04 12:20:23 +08:00
Wu Clan f0e6ed9985 Update the changelog for v1.12.1 (#983) 2025-12-31 15:36:25 +08:00
Wu Clan 809879997c Update git and docker ignore files (#981) 2025-12-30 12:20:33 +08:00
Wu Clan b153b7ac18 Add observability instrument for httpx request (#980) 2025-12-30 12:16:59 +08:00
Wu Clan 308ccf71ae Bump dependencies and pre-commits (#979) 2025-12-29 15:47:46 +08:00
Wu Clan c8f6d9da72 Add the auto init project CLI command (#978) 2025-12-28 14:12:31 +08:00
Wu Clan 3f2c3e44ba Optimize login log database session calls (#977) 2025-12-25 18:12:43 +08:00
Wu Clan b5e2a9e591 Fix case where the user agent was empty (#976) 2025-12-25 12:03:57 +08:00
Wu Clan cdabe91092 Add OTEL semantic specification metrics config (#973) 2025-12-24 10:49:25 +08:00
Wu Clan 69a59993e3 Add observability instrument for redis client (#972) 2025-12-20 19:25:34 +08:00
Wu Clan b6defc671e Fix i18n target language error when concurrent (#970) 2025-12-20 19:25:05 +08:00
Wu Clan 8cbea57a8a Add an independent contribution document (#971) 2025-12-19 15:20:34 +08:00
Wu Clan 7845e1ff49 Fix support for special character passwords (#968)
* Fix special character password support in celery

* Fix celery amqp and socketio password

* Remove non-essential changes
2025-12-19 14:47:26 +08:00
Wu Clan 2b76b64e85 Add code generation and notice SQL scripts (#966) 2025-12-16 19:55:01 +08:00
Wu Clan dd3165a082 Rename the pre start script to migrate (#965) 2025-12-16 19:54:12 +08:00
Wu Clan 407d12760b Update Grafana security and user default config (#964) 2025-12-16 14:39:21 +08:00
Wu Clan f8b244c3a9 Update the changelog for v1.12.0 (#963) 2025-12-15 17:11:29 +08:00
Wu Clan dfc715aad8 Update the version number to 1.12.0 (#962) 2025-12-15 17:07:06 +08:00
Wu Clan 4bc5ba53e6 Add the Grafana observability suite (#961)
* Add the Grafana observability suite

* Update configs

* Update docker script directory structure

* Fix the otel trace id

* Add grafana ini

* Fix some configs and loguru integration

* Add the celery grafana

* Update Grafana dashboards

* Update configs

* Fix issues with the panel

* Update grafana configs

* Update grafana dashboards

* Optimized panel styles

* Add sqlalchemy traces

* Fix the CORS

* Update the grafana query and config

* Update grafana status is off by default
2025-12-15 17:06:43 +08:00
曾果 6b4fd93e5f Fix the IP address in the request log (#959) 2025-12-15 10:07:43 +08:00
Wu Clan 0c5956f828 Fix CLI command for code generation (#956) 2025-12-07 15:14:52 +08:00
Wu Clan 122d17ab57 Update the init project database CLI to subcommand (#954) 2025-12-05 17:23:44 +08:00
Wu Clan 4fb14e480a Add CLI init project database support (#952)
* Add CLI init project database support

* Update get sql script

* Update init CLI contexts
2025-12-05 17:03:01 +08:00
Wu Clan ea4ac6ff41 Optimize the coupling of code generation CLI (#951) 2025-12-05 14:52:58 +08:00
Wu Clan bb5bcdf430 Add the database primary key mode config (#953)
* Add the database primary key mode config

* Update auto to autoincrement
2025-12-05 14:52:12 +08:00
Wu Clan aad9afa9bb Optimize the coupling of user social plugin (#950) 2025-12-04 11:56:37 +08:00
Wu Clan a0e6e12d85 Update pre-commit to prek in pre-commit script (#949) 2025-12-04 11:56:00 +08:00
Wu Clan 5d680ff93f Optimize data permission logic and usage (#947)
* Optimize data permission rules and usage

* Update get data permission models

* Update date permission filter

* Optimize the target model logic

* Upgrade dependencies to use latest features

* Remove model warnings

* Fix the latest feature issues

* Fix the sqlalchemy Table class import

* Fix the sqlalchemy Table class compatibility
2025-12-03 18:44:18 +08:00
Wu Clan 866b0e6ba4 Fix super value in update user permissions (#948) 2025-12-03 18:41:26 +08:00
Wu Clan 5a49d20c9b Update celery related docker container independence (#943)
* Update celery related docker container independence

* Fix lint

* Fix nginx conf
2025-11-28 18:33:32 +08:00
Wu Clan 7641d5993a Update the changelog for v1.11.2 (#942) 2025-11-28 12:31:05 +08:00
Wu Clan 07d66137cb Update the version number to 1.11.2 (#940) 2025-11-28 12:28:52 +08:00
Wu Clan f918dcd9c2 Update the fba run CLI command output (#941) 2025-11-28 12:28:18 +08:00
Wu Clan ed4e312cb1 Update code generation interface definitions (#939)
* Update code generation interface definitions

* Update more

* Resume irrelevant updates
2025-11-27 19:20:44 +08:00
Wu Clan 0b539f868e Update task application interface definitions (#938)
* Update the task execution interface definition

* Update more
2025-11-27 19:20:03 +08:00
Wu Clan 8899832de9 Update the files interface filename to file (#937) 2025-11-27 16:26:04 +08:00
Wu Clan d844aa323a Bump dependencies and pre-commits (#936) 2025-11-26 11:14:41 +08:00
Wu Clan ef640b0b5c Fix get column types in code generation (#935) 2025-11-25 18:07:09 +08:00
Wu Clan 4d43c6a3ea Update the i18n language file init location (#934) 2025-11-25 18:06:34 +08:00
Wu Clan 407c848145 Optimize the use of some LRU caches (#932) 2025-11-25 16:48:55 +08:00
Wu Clan d58260a0c0 Add env reqs for plugin install and uninstall (#929) 2025-11-21 17:18:27 +08:00
Dylan 16bcaf963d Add distributed deployment support for snowflake ID (#927)
* feat: Add support distributed deployment for Snowflake

* Update the algorithm implementation

* Remove duplicate codes and update error messages
2025-11-19 12:48:24 +08:00
Wu Clan 551dc51c52 Restore captcha uuid naming in the login params (#928) 2025-11-18 19:58:37 +08:00
Wu Clan 0729061438 Fix typos in data rule sql scripts (#926) 2025-11-18 19:33:24 +08:00
Wu Clan 636d867544 Update the changelog for v1.11.1 (#923) 2025-11-16 21:49:29 +08:00
Wu Clan cf9e5dc4f4 Update user and login security configs (#922)
* Update user and login security configs

* Optimize some code definitions

* Update config comments

* Update the captcha check

* Update the config plugin sql scripts

* Add user password history model to init

* Fix some logic errors

* Add last_password_changed_time to user sql

* Fix user update password

* Fix the dynamic config check

* Update the user sql style
2025-11-16 21:23:36 +08:00
linrong 2c0acb1103 Fix the user list query serialization (#921) 2025-11-16 16:04:22 +08:00
Wu Clan 425bc202c2 Add user social binding and unbinding (#919)
* Add user social binding and unbinding

* Add oauth2 state to binding

* Update oauth2 state

* Fix imports

* Update some interface definitions

* Update the authorization type security
2025-11-15 17:03:30 +08:00
Wu Clan 3b24dca935 Fix missing table in alembic migration (#920) 2025-11-14 17:55:15 +08:00
Wu Clan 98db5f4984 Update the changelog for v1.11.0 (#917) 2025-11-12 13:09:47 +08:00
Wu Clan 2b56168ad0 Refactor foreign keys and relationships to pure logic (#901)
* Refactor foreign keys and relationships to pure logic

* Revert of some changes

* More revert

* Update the user paginate

* Update user create and update

* Update dept select and delete

* Rename the join query functions

* Update select_join_serialize doc and README

* Fix typo in README

* Update the user delete

* Update the user social

* Update the dict plugin crud

* Update the dict plugin version

* Bump dependencies and pre-commits

* Update the code generator plugin crud

* Update the menu crud

* Update the role crud

* Update the data scope and rule crud

* Restore get_paginated to get_select

* Update the code generator plugin version

* Add the py version in pre-commit

* Remove the plugin include parameter config

* Add more cache cleaning TODO

* Rename get_with_relation to get_join

* Add the user cache clear

* Fix known compatibility issues

* Update the version number to 1.11.0

* Fix lint

* Optimize select_join_serialize logic

* Delete cache cleanup comments

* Update the oauth2 plugin version

* Fix user-role table cleanup when user update
2025-11-12 13:06:26 +08:00
Wu Clan b9255815e1 Update the changelog for v1.10.4 (#916) 2025-11-12 12:01:57 +08:00
Wu Clan 93e2f0f5f9 Fix dept and menu table subqueries (#914)
* Fix dept and menu table subqueries

* Fix user cache cleanup when updating data scope
2025-11-11 17:31:59 +08:00
Wu Clan 437c026da9 Fix user cache cleanup when updating data scope (#915) 2025-11-11 17:30:43 +08:00
Wu Clan 316079045f Add user social unbinding account interface (#913) 2025-11-11 17:06:11 +08:00
Wu Clan 5ae9f8b288 Fix the user menu sidebar parsing (#912) 2025-11-11 17:05:43 +08:00
Wu Clan bef65e1baa Fix create and delete department validations (#911) 2025-11-11 17:05:24 +08:00
Wu Clan 87a3cd8a9c Fix user cache cleanup when operating data rules (#910) 2025-11-11 17:05:08 +08:00
Wu Clan be7b742891 Fix typo in the data permission prompt (#909) 2025-11-11 17:04:25 +08:00
Wu Clan 1f98797a60 Update the version number to 1.10.4 (#907) 2025-11-10 23:51:28 +08:00
Wu Clan 8505e32757 Add dept validation to user updates (#906) 2025-11-10 23:50:38 +08:00
Wu Clan 91297d1ccd Fix response status codes in the request logs (#905) 2025-11-08 16:40:16 +08:00
Wu Clan 0ea213edfd Optimize the data permission condition build (#904) 2025-11-08 16:35:15 +08:00
Wu Clan 292f5d7e48 Optimize the request params of the service layer (#903) 2025-11-08 16:24:19 +08:00
Wu Clan ef871f3b5c Add the user social independent enum file (#902) 2025-11-08 16:21:51 +08:00
Wu Clan 6b17da59f8 Update the time column type in the task (#900) 2025-11-06 21:04:51 +08:00
Wu Clan 86d580edd8 Optimize redis batch get and delete operations (#899)
* Optimize redis batch get and delete operations

* Fix typo
2025-11-04 20:59:45 +08:00
Wu Clan cdbe37de7d Update the interface timing accuracy in log (#897)
* Update the interface timing accuracy in log

* Update log request finish
2025-11-04 20:59:00 +08:00
Wu Clan f46974ddd9 Bump fastapi oauth2 from 0.0.1 to 0.0.2 (#896) 2025-11-02 19:42:51 +08:00
Wu Clan a14d4243d9 Update the changelog for v1.10.3 (#895) 2025-10-30 19:28:00 +08:00
Wu Clan 90588ebae8 Add the celery rabbitmq vhost config (#894) 2025-10-30 19:24:46 +08:00
Wu Clan 00bc02f49c Fix the venv pip availability in Linux (#893)
* Fix the venv pip availability in Linux

* Remove pip install timeout
2025-10-30 18:06:58 +08:00
Wu Clan 7e9ce2e691 Fix celery compatibility with psycopg version (#892) 2025-10-30 13:32:12 +08:00
Wu Clan 81ec0f0bd9 Fix import in code generation api template (#891) 2025-10-30 11:14:05 +08:00
Wu Clan 37f9716c4f Bump dependencies to the latest version (#890)
* Bump dependencies to the latest version

* Bump fastapi to the latest version

* Fix lint
2025-10-30 11:07:40 +08:00
Wu Clan 4540cd9338 Update the length style of the model columns (#883)
* Update the length style of the model columns

* Fix opera log table username column length

* Fix login log table username column length
2025-10-23 15:00:28 +08:00
Wu Clan 3c39d2f2f3 Add Google OAuth2 callback to opera log exclusion (#882) 2025-10-22 18:58:36 +08:00
Wu Clan 1e01ffc3d9 Fix the OAuth2 link acquisition in HTTPS (#881)
* Fix the OAuth2 link acquisition in HTTPS

* Update OAUTH2_BACKEND_URL to OAUTH2_BACKEND_BASE_URL
2025-10-22 18:52:40 +08:00
Wu Clan f4b1c43b55 Update the serializer of httpurl type (#879)
* Update the serializer of httpurl type

* Fix lint
2025-10-22 17:30:13 +08:00
Wu Clan 02254938b4 Update the length of the user email column (#878) 2025-10-22 15:58:50 +08:00
Wu Clan 054ff0cab2 Fix the httpurl type compatibility with postgresql (#877)
* Fix the httpurl type compatibility with postgresql

* Add noqa comments
2025-10-22 15:58:42 +08:00
Wu Clan 4f898a0264 Update the container naming in docker scripts (#876) 2025-10-22 15:33:10 +08:00
Wu Clan 70d90c2c01 Add port for fba sever in docker compose (#875) 2025-10-22 15:32:56 +08:00
Wu Clan 964143b30d Fix docker default database env variables (#874) 2025-10-22 14:01:18 +08:00
Wu Clan ce9f59e7d6 Update the changelog for v1.10.2 (#873) 2025-10-21 15:56:46 +08:00
Wu Clan 1f958fc42a Bump fastapi to remove warning for python 3.14 (#872) 2025-10-21 15:45:05 +08:00
Wu Clan e6e53296a8 Optimize sqlalchemy types to simplify compatibility (#870)
* Optimize sqlalchemy types to simplify compatibility

* Update TimeZone type comment
2025-10-20 17:41:00 +08:00
Wu Clan f533de8abd Bump rtoml and uvicorn to support python 3.14 (#871) 2025-10-20 12:42:28 +08:00
Wu Clan adc6427aab Update the changelog for v1.10.1 (#869) 2025-10-18 18:29:30 +08:00
Wu Clan d034b78b48 Bump dependencies to the latest version (#868) 2025-10-18 18:14:43 +08:00
Wu Clan 6deda906de Update the release changelog workflow (#867) 2025-10-18 18:08:29 +08:00
Wu Clan a063105de8 Fix the volume in the docker compose script (#865)
* Fix the volume in the docker compose script

* Fix the db comment
2025-10-18 15:57:29 +08:00
Wu Clan 02b4215742 Fix tasks related to deleting db logs (#863) 2025-10-18 15:49:55 +08:00
Wu Clan 56b6e2113f Update the changelog for v1.10.0 (#864) 2025-10-18 15:44:17 +08:00
Wu Clan 4fcc78eb94 Update the changelog for v1.9.0 (#862) 2025-10-18 15:41:28 +08:00
Wu Clan b28a4d7673 Update the primary database to postgresql (#829)
* Update the primary database to postgresql

* Delete the prints

* Fix lint

* Update models in code generation

* Delete print

* Fix the salt column type
2025-10-17 23:03:17 +08:00
Wu Clan 27383e4ff5 Fix ctx in validation exception handler (#861)
* Fix ctx in validation exception handler

* Fix lint
2025-10-17 16:21:28 +08:00
Wu Clan bf16f8916d Update the changelog for v1.9.0 (#856) 2025-10-17 13:05:31 +08:00
Wu Clan 75a62ac6aa Fix dict plugin pgsql init script missing (#857)
* Fix dict plugin pgsql init script missing

* Fix lint
2025-10-17 13:05:10 +08:00
Wu Clan bbc4aaa020 Fix the api ninja in code generation (#858) 2025-10-17 13:04:54 +08:00
Wu Clan 89e5d02fe2 Update unique judgment logic for dict data (#859) 2025-10-17 13:04:35 +08:00
Wu Clan a93973af06 Fix the import table cli command (#860) 2025-10-17 13:02:28 +08:00
Wu Clan 15a9527866 Refactor the service layer db session call method (#850)
* Refactor the service layer db session call method

* Fix lint

* Update paging interfaces

* Fix some errors
2025-10-16 12:49:56 +08:00
Wu Clan 9147d35c8c Fix safely of access ctx in exception handlers (#855) 2025-10-16 12:45:01 +08:00
Wu Clan 6a065797f5 Update request state usage to context variable (#853)
* Update request state usage to context variable

* Update context to custom ctx

* Fix the exception interception in opera log

* restore elapsed
2025-10-16 10:23:39 +08:00
Wu Clan dd08775c85 Fix the superuser verify missing auth (#854) 2025-10-14 17:55:54 +08:00
Wu Clan 0cbe635ed8 Update superuser verify to dependency injection (#852)
* Update superuser verify to dependency injection

* Update sql scripts
2025-10-14 17:38:47 +08:00
Wu Clan e923f13e83 Bump fastapi and pydantic to the latest (#851) 2025-10-14 15:18:35 +08:00
Wu Clan 889da744ef Update the changelog for v1.8.3 (#849) 2025-10-13 17:31:23 +08:00
Wu Clan 74df1d1ce2 Bump sqlalchemy crud plus from 0.12.0 to 0.12.1 (#848) 2025-10-13 10:45:49 +08:00
Wu Clan 54e09b68c7 Bump fastapi and sqlalchemy to support python 3.14 (#847) 2025-10-11 13:19:10 +08:00
Wu Clan 5762834744 Update the ruff rules and format the code (#846)
* Update the ruff rules and format the code

* Update the per-file-ignores

* Update the ci

* Update rules

* Fix codes

* Fix pagination

* Update rules
2025-10-10 19:02:49 +08:00
Wu Clan 51354593d0 Bump dependencies and pre-commits (#845) 2025-10-09 13:26:56 +08:00
Wu Clan 8751425e2b Fix SQL script syntax error in config plugin (#843) 2025-09-27 12:33:58 +08:00
Wu Clan 921f05fa19 Fix the pgsql script in dict plugin (#842) 2025-09-27 12:17:45 +08:00
Wu Clan 5754c5449d Update the changelog for v1.8.2 (#841) 2025-09-26 13:32:05 +08:00
368 changed files with 16114 additions and 16987 deletions
+4
View File
@@ -3,7 +3,11 @@ __pycache__/
.github/
.idea/
.vscode/
.cursor/
venv/
.venv/
.ruff_cache/
.pytest_cache/
.claude/
.serena/
.logs/
+8 -4
View File
@@ -1,16 +1,18 @@
name: Release changelog
on:
push:
tags:
- v*
workflow_run:
workflows: ['Check version']
types:
- completed
jobs:
changelog:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: master
@@ -18,4 +20,6 @@ jobs:
with:
file: CHANGELOG.md
pull_request: true
commit_summary_template: 'Update the changelog for %s'
args: -c
github_token: ${{ secrets.GH_TOKEN }}
+3 -3
View File
@@ -12,13 +12,13 @@ jobs:
name: lint ${{ matrix.python-version }}
strategy:
matrix:
python-version: [ '3.10', '3.11', '3.12', '3.13' ]
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
+25
View File
@@ -0,0 +1,25 @@
name: Check version
on:
push:
tags:
- v*
jobs:
check-version:
name: check version
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.13
- name: Check the package version
uses: samuelcolvin/check-python-version@v5
with:
version_file_path: backend/__init__.py
+8
View File
@@ -7,3 +7,11 @@ venv/
.python-version
.ruff_cache/
.pytest_cache/
logs/
.claude/
.serena/
.agents/
.superpowers/
superpowers/
.codegraph/
.pi/
+16 -6
View File
@@ -1,25 +1,35 @@
default_language_version:
python: '>= 3.10'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: check-json
- id: check-yaml
- id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.13.2
- repo: https://github.com/tombi-toml/tombi-pre-commit
rev: v1.3.0
hooks:
- id: ruff
- id: tombi-lint
args: ["--offline"]
- id: tombi-format
args: ["--offline"]
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.16.3
hooks:
- id: ruff-check
args:
# https://github.com/astral-sh/ruff-pre-commit/issues/64
- '--config'
- 'backend/.ruff.toml'
- '--fix'
- '--unsafe-fixes'
- id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.8.22
rev: 0.12.3
hooks:
- id: uv-lock
- id: uv-export
+153
View File
@@ -0,0 +1,153 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "FBA Plugin Manifest Schema",
"description": "JSON Schema for FastAPI Best Architecture plugin.toml files. See: https://fastapi-practices.github.io/fastapi_best_architecture_docs/plugin/dev.html",
"type": "object",
"required": ["plugin"],
"additionalProperties": false,
"properties": {
"plugin": {
"type": "object",
"description": "Plugin metadata",
"required": ["summary", "version", "description", "author", "tags"],
"additionalProperties": false,
"x-tombi-table-keys-order": "schema",
"properties": {
"icon": {
"type": "string",
"description": "Icon path (plugin repository icon path or URL)"
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Brief summary (1-100 characters)"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Plugin version (semver format: x.y.z)"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Detailed description (1-500 characters)"
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 50,
"description": "Plugin author (1-50 characters)"
},
"tags": {
"type": "array",
"minItems": 1,
"description": "Plugin tags for categorization",
"items": {
"type": "string",
"enum": ["ai", "mcp", "agent", "auth", "storage", "notification", "task", "payment", "other"]
},
"x-tombi-array-values-order": "ascending"
},
"database": {
"type": "array",
"minItems": 1,
"description": "Supported databases. Required for app-level and extend-level plugins, optional for capability-level plugins without models.",
"items": {
"type": "string",
"enum": ["mysql", "postgresql"]
},
"x-tombi-array-values-order": "ascending"
},
"depends_on": {
"type": "array",
"description": "List of plugin names this plugin depends on (used for startup order)",
"items": {
"type": "string",
"minLength": 1
},
"x-tombi-array-values-order": "ascending"
}
}
},
"app": {
"type": "object",
"description": "Application configuration. For app-level plugins: use 'router'. For extend-level plugins: use 'extend'.",
"additionalProperties": false,
"minProperties": 1,
"x-tombi-table-keys-order": "schema",
"properties": {
"extend": {
"type": "string",
"minLength": 1,
"description": "Parent app folder name (for extension-level plugins)"
},
"router": {
"type": "array",
"minItems": 1,
"description": "Router instances (for application-level plugins)",
"items": {
"type": "string",
"minLength": 1
},
"x-tombi-array-values-order": "version-sort"
}
}
},
"settings": {
"type": "object",
"description": "Plugin base configuration (hot-pluggable, uppercase keys only)",
"x-tombi-additional-key-label": "SETTING_NAME",
"x-tombi-table-keys-order": "ascending",
"propertyNames": {
"pattern": "^[A-Z][A-Z0-9_]*$"
},
"additionalProperties": {
"oneOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" },
{
"type": "array",
"items": { "type": "string" }
}
]
}
},
"api": {
"type": "object",
"description": "API endpoint configurations (for extension-level plugins). The key (e.g., 'xxx' in api.xxx) corresponds to the API filename without extension.",
"x-tombi-additional-key-label": "api_filename",
"x-tombi-table-keys-order": "ascending",
"minProperties": 1,
"propertyNames": {
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
},
"additionalProperties": {
"type": "object",
"required": ["prefix", "tags"],
"additionalProperties": false,
"x-tombi-table-keys-order": {"properties": "schema"},
"properties": {
"prefix": {
"type": "string",
"minLength": 1,
"pattern": "^/[a-zA-Z0-9_/-]*$",
"description": "URL prefix for the API (must start with '/', allowed chars: a-z, A-Z, 0-9, _, -, /)"
},
"tags": {
"type": "string",
"minLength": 1,
"description": "OpenAPI tags for Swagger documentation"
}
}
}
}
}
}
+1425 -602
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
## Backend
1. Prerequisites
- Python >= 3.10
- Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account
2. Installation and setup
Clone your forked repository:
```shell
git clone https://github.com/<your account>/fastapi_best_architecture.git
```
Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv run fba init --auto
```
3. Checkout
Checkout a new branch and make your changes
```shell
git checkout -b your-new-feature-branch
```
4. Format and Lint
```shell
fba format
```
5. Commit and push
Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts
> [!WARNING]
>
> The following script may not apply to the Windows platform
>
> It is recommended to execute under the backend directory, and chmod authorization may be required
- `migrate.sh`: Perform automatic database migration
- `scripts/format.sh`: Perform ruff format with preview
- `scripts/lint.sh`: Perform prek formatting
- `scripts/export.sh`: Execute uv export dependency package
+17 -44
View File
@@ -1,68 +1,41 @@
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args
ARG SERVER_TYPE=fastapi_server
# Python environment from uv
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim AS builder
# === Python environment from uv ===
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
# Used for build Python packages
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends gcc python3-dev \
&& apt-get install -y --no-install-recommends gcc make python3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . /fba
WORKDIR /fba
# Configure uv environment
ENV UV_COMPILE_BYTECODE=1 \
UV_NO_CACHE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/usr/local
# Install dependencies with cache
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group server
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-default-groups --group server --no-install-project
# === Runtime base server image ===
FROM python:3.10-slim AS base_server
# Preinstall plugin dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
# Single FastAPI server image
FROM ghcr.io/astral-sh/uv:python3.10-trixie-slim
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& apt-get install -y --no-install-recommends curl ca-certificates supervisor \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /fba /fba
COPY --from=builder /usr/local /usr/local
COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /fba/backend
# === FastAPI server image ===
FROM base_server AS fastapi_server
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fastapi_server
RUN mkdir -p /var/log/fba
EXPOSE 8001
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"]
# === Celery server image ===
FROM base_server AS celery
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/celery
RUN chmod +x celery-start.sh
EXPOSE 8555
CMD ["./celery-start.sh"]
# Build image
FROM ${SERVER_TYPE}
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
+4 -75
View File
@@ -1,78 +1,7 @@
<div align="center">
# FastAPI Best Architecture Slim
<img alt="The logo includes the abstract combination of the three letters FBA, forming a lightning bolt that seems to spread out from the ground" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
专注于基本架构的 API 开发简化版本
# FastAPI Best Architecture
完整版请查看 [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi-best-architecture)
Enterprise-level backend architecture solution
English | [简体中文](./README.zh-CN.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-%2300758f)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16.0%2B-%23336791)
![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-%23778877)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div>
## Pseudo 3-tier architecture
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating
In python web development, there is no common standard for the concept of 3-tier architecture, so we'll call it a
pseudo 3-tier architecture here
But please note that we don't have a traditional multi-app structure (django, springBoot...) If you don't like this
pattern, use templates to transform it to your heart's content!
| workflow | java | fastapi_best_architecture |
|----------------|----------------|---------------------------|
| view | controller | api |
| data transmit | dto | schema |
| business logic | service + impl | service |
| data access | dao / mapper | crud |
| model | model / entity | model |
## Help
For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## Contributors
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
</a>
## Special thanks
- [FastAPI](https://fastapi.tiangolo.com/)
- [Pydantic](https://docs.pydantic.dev/latest/)
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
- [Casbin](https://casbin.org/zh/)
- [Ruff](https://beta.ruff.rs/docs/)
- ...
## Interactivity
[Discord](https://wu-clan.github.io/homepage/)
## Sponsor us
If this program has helped you, you can sponsor us with some coffee
beans: [:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/)
## License
This project is licensed by the terms of
the [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) license
[![Stargazers over time](https://starchart.cc/fastapi-practices/fastapi_best_architecture.svg?variant=adaptive)](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
简体中文 | [English](./README.zh-CN.md)
+4 -70
View File
@@ -1,73 +1,7 @@
<div align="center">
# FastAPI Best Architecture Slim
<img alt="Logo 包含了 FBA 三个字母抽象结合,形成了一个类似从地面扩散投影上来的闪电" width="320" src="https://wu-clan.github.io/picx-images-hosting/logo/fba.png">
A simplified version focused on basic architecture for API development
# FastAPI Best Architecture
See the [full FastAPI Best Architecture project](https://github.com/fastapi-practices/fastapi-best-architecture)
企业级后端架构解决方案
简体中文 | [English](./README.md)
[![GitHub](https://img.shields.io/github/license/fastapi-practices/fastapi_best_architecture)](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-%2300758f)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16.0%2B-%23336791)
![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-%23778877)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
![Docker](https://img.shields.io/badge/Docker-%232496ED?logo=docker&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?logo=discord&logoColor=white)](https://discord.com/invite/yNN3wTbVAC)
![Discord](https://img.shields.io/discord/1185035164577972344)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/fastapi-practices/fastapi_best_architecture)
</div>
## 伪三层架构
mvc 架构作为常规设计模式,在 python web 中也很常见,但是三层架构更令人着迷
在 python web 开发中,三层架构的概念并没有通用标准,所以这里我们称之为伪三层架构
但请注意,我们并没有传统的多应用程序结构(django、springBoot...),如果您不喜欢这种模式,可以使用模板对其进行随意改造!
| 工作流程 | java | fastapi_best_architecture |
|------|----------------|---------------------------|
| 视图 | controller | api |
| 数据传输 | dto | schema |
| 业务逻辑 | service + impl | service |
| 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model |
## 帮助
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## 贡献者
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
<img src="https://contrib.rocks/image?repo=fastapi-practices/fastapi_best_architecture"/>
</a>
## 特别鸣谢
- [FastAPI](https://fastapi.tiangolo.com/)
- [Pydantic](https://docs.pydantic.dev/latest/)
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/)
- [Casbin](https://casbin.org/zh/)
- [Ruff](https://beta.ruff.rs/docs/)
- ...
## 互动
[Discord](https://wu-clan.github.io/homepage/)
## 赞助我们
如果此项目能够帮助到你,你可以赞助作者一些咖啡豆表示鼓励:[:coffee: Sponsor :coffee:](https://wu-clan.github.io/sponsor/)
## 许可证
本项目由 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
[![Stargazers over time](https://starchart.cc/fastapi-practices/fastapi_best_architecture.svg?variant=adaptive)](https://starchart.cc/fastapi-practices/fastapi_best_architecture)
English | [简体中文](./README.md)
+304
View File
@@ -0,0 +1,304 @@
# FBA-Slim 瘦身指南
> 本文档记录了 fba-slim 与 fba 完整版之间的差异,方便日后从完整版合并代码时快速瘦身
## 功能差异概览
| 模块 | 完整版 | 精简版 | 说明 |
|-------------------------------|-----|-----|-----------------------------|
| 用户认证 (JWT) | ✅ | ✅ | 完整保留 |
| RBAC (角色/菜单/部门/权限校验) | ✅ | ❌ | 整体移除,仅保留 `DependsSuperUser` |
| 用户 CRUD | ✅ | ✅ | 保留(移除 dept/role 关联) |
| 操作日志/登录日志 | ✅ | ❌ | 移除 DB 日志,中间件仅控制台输出 |
| 多级缓存 (Local + Redis + PubSub) | ✅ | ✅ | 完整保留 |
| Snowflake 分布式 ID | ✅ | ✅ | 完整保留 |
| 文件上传 | ✅ | ✅ | 完整保留 |
| 密码安全/历史记录 | ✅ | ✅ | 完整保留 |
| config 插件 | ✅ | ✅ | 保留 |
| 插件核心系统 | ✅ | ✅ | 保留插件安装/卸载机制,内置仅 `config` |
| Celery 任务系统 | ✅ | ❌ | 整体移除 |
| Socket.IO 实时通信 | ✅ | ❌ | 整体移除 |
| Prometheus + OTel 可观测性 | ✅ | ❌ | 整体移除 |
| 监控 API (online/redis/server) | ✅ | ❌ | 整体移除 |
| 数据权限 (DataRule/DataScope) | ✅ | ❌ | 整体移除 |
| dict 插件 | ✅ | ❌ | 整体移除 |
| email 插件 | ✅ | ❌ | 整体移除 |
| notice 插件 | ✅ | ❌ | 整体移除 |
| oauth2 插件 | ✅ | ❌ | 整体移除 |
| code_generator 插件 | ✅ | ❌ | 整体移除 |
## 插件范围
精简版仓库只内置 `config` 插件,`backend/plugin/` 下不得保留其他业务插件目录
- `backend/core/conf.py``PLUGIN_REQUIRED` 固定为 `['config']`
- `backend/plugin/config/` 是唯一随仓库发布的插件
- 插件核心、CLI 和插件管理 API 仍然保留,用于后续按需安装外部插件
- 不保留 AI、dict、email、notice、oauth2、code_generator 等插件及其依赖
---
## 已删除的目录
```
backend/app/task/ # Celery 任务系统
backend/common/socketio/ # Socket.IO 实时通信
backend/common/observability/ # Prometheus + OpenTelemetry
backend/app/admin/api/v1/monitor/ # 监控 API (online/redis/server)
backend/app/admin/tests/ # 测试文件
backend/app/admin/api/v1/log/ # 日志 API (login_log/opera_log)
backend/plugin/dict/ # 字典插件
backend/plugin/email/ # 邮件插件
backend/plugin/notice/ # 通知插件
backend/plugin/oauth2/ # OAuth2 插件
backend/plugin/code_generator/ # 代码生成插件
backend/plugin/ai/ # AI 插件
deploy/backend/grafana/ # Grafana 部署配置
```
## 已删除的文件
```
# RBAC (角色/菜单/部门)
backend/app/admin/model/role.py
backend/app/admin/model/menu.py
backend/app/admin/model/dept.py
backend/app/admin/model/m2m.py
backend/app/admin/schema/role.py
backend/app/admin/schema/menu.py
backend/app/admin/schema/dept.py
backend/app/admin/crud/crud_role.py
backend/app/admin/crud/crud_menu.py
backend/app/admin/crud/crud_dept.py
backend/app/admin/service/role_service.py
backend/app/admin/service/menu_service.py
backend/app/admin/service/dept_service.py
backend/app/admin/api/v1/sys/role.py
backend/app/admin/api/v1/sys/menu.py
backend/app/admin/api/v1/sys/dept.py
backend/common/security/rbac.py
backend/common/security/permission.py
backend/utils/build_tree.py
# 数据权限
backend/app/admin/model/data_rule.py
backend/app/admin/model/data_scope.py
backend/app/admin/schema/data_rule.py
backend/app/admin/schema/data_scope.py
backend/app/admin/crud/crud_data_rule.py
backend/app/admin/crud/crud_data_scope.py
backend/app/admin/service/data_rule_service.py
backend/app/admin/service/data_scope_service.py
backend/app/admin/api/v1/sys/data_rule.py
backend/app/admin/api/v1/sys/data_scope.py
# 监控 API
backend/app/admin/schema/monitor.py
# 可观测性
backend/common/observability/otel.py
backend/common/observability/prometheus/fastapi.py
backend/common/observability/prometheus/queue.py
backend/common/observability/prometheus/sqlalchemy.py
# 日志系统
backend/app/admin/model/login_log.py
backend/app/admin/model/opera_log.py
backend/app/admin/schema/login_log.py
backend/app/admin/schema/opera_log.py
backend/app/admin/crud/crud_login_log.py
backend/app/admin/crud/crud_opera_log.py
backend/app/admin/service/login_log_service.py
backend/app/admin/service/opera_log_service.py
backend/common/queue.py
# 部署
deploy/backend/supervisor/fba_celery_beat.conf
deploy/backend/supervisor/fba_celery_flower.conf
deploy/backend/supervisor/fba_celery_worker.conf
deploy/backend/grafana/dashboards/fba_celery.json
```
---
## 已修改的文件
### 模型层
| 文件 | 修改内容 |
|---------------------------------------|-------------------------------------|
| `backend/app/admin/model/__init__.py` | 仅保留 `User``UserPasswordHistory` 导出 |
| `backend/app/admin/model/user.py` | 删除 `dept_id` 字段 |
### Schema 层
| 文件 | 修改内容 |
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/app/admin/schema/user.py` | 删除 `dept_id`/`roles` 字段、`AddUserRoleParam`/`GetUserInfoWithRelationDetail`/`GetCurrentUserInfoWithRelationDetail`/`AddOAuth2UserParam` 类 |
### CRUD 层
| 文件 | 修改内容 |
|---------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| `backend/app/admin/crud/crud_user.py` | 删除 Role/Dept/Menu/m2m 全部引用,移除 `get_join()` 方法、JoinConfig、m2m 操作;`add()`/`update()`/`delete()`/`get_select()` 简化 |
### Service 层
| 文件 | 修改内容 |
|---------------------------------------------|-----------------------------------------------------------------------------------|
| `backend/app/admin/service/user_service.py` | 删除 `get_roles()` 方法、dept/role 验证逻辑、`dept` 参数;`get_userinfo()` 改用 `user_dao.get()`/`get_by_username()``update_email()` 删除邮箱验证码(email 插件已移除),保留邮箱唯一性校验 |
| `backend/app/admin/service/auth_service.py` | 删除 `login_log_service`/`menu_dao` 引用、`get_codes()` 方法、`background_tasks` 参数 |
### Utils/安全层
| 文件 | 修改内容 |
|------------------------------------|------------------------------------------------------------------------------------------------------|
| `backend/app/admin/utils/cache.py` | 删除 `clear_by_role_id()`/`clear_by_menu_id()`/`clear_by_data_scope_id()`/`clear_by_data_rule_id()` 方法 |
| `backend/utils/trace_id.py` | 删除 `OtelTraceIdPlugin` 类 |
| `backend/utils/dynamic_config.py` | 保留上游懒加载 config 插件实现,删除邮箱插件相关 `load_email_config()`,仅保留用户安全与登录配置加载 |
### API 层
| 文件 | 修改内容 |
|----------------------------------------------|-------------------------------------------------------------------------------------------------|
| `backend/app/admin/api/v1/sys/user.py` | 删除 `get_user_roles` 路由、`dept` 参数、`update_user_email` 的 captcha 参数;`delete_user` 改用 `DependsSuperUser`;响应类型改为 `GetUserInfoDetail` |
| `backend/app/admin/api/v1/sys/file.py` | `RequestPermission + DependsRBAC` 改为 `DependsJwtAuth` |
| `backend/app/admin/api/v1/sys/__init__.py` | 仅保留 `user_router``file_router``plugin_router` |
| `backend/app/admin/api/v1/auth/auth.py` | 删除 `get_codes` 路由、`background_tasks` 参数 |
| `backend/app/admin/api/router.py` | 删除 `monitor_router``log_router` |
| `backend/app/router.py` | 删除 `task_v1` |
| `backend/app/admin/api/v1/sys/plugin.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
| `backend/plugin/config/api/v1/sys/config.py` | `RequestPermission + DependsRBAC` 改为 `DependsSuperUser` |
### JWT/中间件层
| 文件 | 修改内容 |
|----------------------------------------------|------------------------------------------------------------------------------------------------------------------|
| `backend/common/security/jwt.py` | `GetUserInfoWithRelationDetail``GetUserInfoDetail``get_current_user()` 改用 `user_dao.get()`,删除 dept/role 状态检查;保留上游 `jwt_authentication_verify``TOKEN_REQUEST_UNDERLYING_SECURITY` |
| `backend/middleware/jwt_auth_middleware.py` | `GetUserInfoWithRelationDetail``GetUserInfoDetail` |
| `backend/middleware/opera_log_middleware.py` | 移除 DB 队列/消费者/入库,改为纯控制台日志输出;异常码读取需包含 `__request_authentication_exception__` |
| `backend/middleware/access_middleware.py` | 删除 Prometheus 导入和 2 处计数器调用 |
### 核心层
| 文件 | 修改内容 |
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/core/registrar.py` | 删除 socketio/prometheus/otel 导入、`register_socket_app()``register_metrics()`、OtelTraceIdPlugin、`create_task(OperaLogMiddleware.consumer())`;保留 lifespan `try/finally` 与上游 `dispose_database()` |
| `backend/core/conf.py` | 删除 CELERY/GRAFANA/DATA_PERMISSION/OAUTH2/EMAIL/WS/CODE_GENERATOR/OPERA_LOG_*/RBAC_ROLE_MENU_*/CACHE_DICT_* 配置段;保留 `DATABASE_SOURCES``TOKEN_REQUEST_UNDERLYING_SECURITY``PLUGIN_REQUIRED` 仅保留 `config` |
| `backend/database/db.py` | 删除 SQLAlchemy 连接池 Prometheus 指标监听 |
| `backend/alembic/env.py` | 保持上游 `get_database_url()` 命名 |
| `backend/main.py` | 保留上游插件准备流程 `_prepare_plugins()`,检查必需插件并安装缺失插件依赖 |
| `backend/plugin/core.py` | `get_required_plugins()` 删除 RBAC 模式相关 `casbin_rbac` 逻辑 |
| `backend/plugin/hooks.py` | 保留上游插件 setup/lifespan hooks,删除 OpenTelemetry hook |
### CLI
| 文件 | 修改内容 |
|------------------|----------------------------------------------------------------------------------|
| `backend/cli.py` | 删除 Celery/代码生成相关命令,保留插件安装卸载与依赖同步;`FbaCli.subcmd``Init \| Run \| Add \| Remove \| Deps \| Format \| Alembic` |
### Enum
| 文件 | 修改内容 |
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| `backend/common/enums.py` | 删除 `MenuType``MethodType``BuildTreeType``RoleDataRuleOperatorType``RoleDataRuleExpressionType``LoginLogStatusType``OperaLogCipherType` 枚举;保留上游 `PluginLevelType.capability` |
| `backend/plugin/config/enums.py` | 仅保留 `ConfigType.user_security``ConfigType.login` |
### SQL 初始化数据
| 文件 | 修改内容 |
|-------------------------------------------------------|-----------------------------------------------------------------------------------|
| `backend/sql/mysql/init_test_data.sql` | 仅保留 sys_user INSERT(删除 dept/menu/role/role_menu/user_role/data_scope/data_rule 等) |
| `backend/sql/mysql/init_snowflake_test_data.sql` | 同上 |
| `backend/sql/postgresql/init_test_data.sql` | 同上 |
| `backend/sql/postgresql/init_snowflake_test_data.sql` | 同上 |
| `backend/plugin/config/sql/**/init*.sql` | 删除 `sys_menu` 初始化与 EMAIL 配置,仅保留 `sys_config` 中用户安全/登录配置 |
| `backend/plugin/config/sql/**/destroy*.sql` | 删除 `sys_menu` 清理语句,仅保留 `sys_config` 清理 |
### 配置/部署
| 文件 | 修改内容 |
|---------------------------------------------|--------------------------------------------------------------------------------------|
| `backend/.env.example` | 删除 Celery/RabbitMQ/OAuth2/Email 环境变量;保留 `DATABASE_SOURCES` |
| `pyproject.toml` | 项目名改为 `fba-slim`;删除 celery/socketio/opentelemetry/prometheus/psutil/flower/gevent/aio-pika/jinja2/psycopg/pymysql 等依赖;保留 `dulwich`(插件 Git 安装仍依赖) |
| `README.md` / `README.zh-CN.md` | 使用 slim 专用说明,并指向完整版仓库 |
| `docker-compose.yml` | 删除 rabbitmq/celery/grafana 全套容器 |
| `Dockerfile` | 简化为单一 server 镜像,删除 celery worker/beat/flower 阶段;保留插件依赖预安装步骤,用于打包随镜像发布的插件依赖 |
| `deploy/backend/docker-compose/.env.docker` | 删除 RabbitMQ/Celery/Grafana 端口映射 |
| `deploy/backend/docker-compose/.env.server` | 删除 Celery/OAuth2/Email 环境变量 |
| `deploy/backend/nginx.conf` | 删除 Flower 代理配置 |
---
## 插件依赖处理约定
- `Dockerfile` 必须保留 `# Preinstall plugin dependencies`,用于镜像构建时为随包插件安装依赖;当前随包插件仅有 `config`
- `backend/main.py` 必须保留上游 `_prepare_plugins()`,服务启动时会检查必需插件并安装缺失插件依赖
- 后续通过插件安装接口安装 zip/git 插件时,仍由 `backend/plugin/requirements.py` 安装该插件依赖
---
## 合并指南
从 fba 完整版同步代码到 fba-slim 后,需要关注以下冲突区域:
### 快速检测 rg 模式
合并后运行以下命令,快速找出需要处理的非 slim 引用:
```bash
# 全量残留扫描:唯一可接受命中是 OperaLogMiddleware 导入(控制台日志保留)
rg -n "celery|CELERY_|app\.task|python-socketio|socketio|prometheus|opentelemetry|psutil|flower|gevent|aio-pika|rabbitmq|GRAFANA_|OAUTH2_|EMAIL_CAPTCHA|CACHE_DICT|RBAC_ROLE_MENU|DATA_PERMISSION|RequestPermission|DependsRBAC|GetUserInfoWithRelation|GetCurrentUserInfoWithRelation|sys_menu|sys_role|sys_dept|data_scope|data_rule|login_log|opera_log|casbin_rbac|OtelTraceIdPlugin|init_plugin_otel|AddOAuth2UserParam" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
# 精确 DB 日志 / RBAC / 数据权限残留扫描:应无命中
rg -n "opera_log_service|login_log_service|OPERA_LOG_|batch_dequeue|opera_log_queue|LoginLog|DataRule|DataScope|RequestPermission|DependsRBAC|GetUserInfoWithRelationDetail|CACHE_DICT_REDIS_PREFIX|EMAIL_CAPTCHA_REDIS_PREFIX" backend pyproject.toml Dockerfile docker-compose.yml deploy -g '!**/__pycache__/**'
# 已移除依赖残留扫描:应无命中
rg -n "celery|celery-aio|opentelemetry|prometheus-client|psutil|python-socketio|flower|gevent|aio-pika|psycopg|pymysql" pyproject.toml
# 内置插件校验:输出必须只有 config
find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort
test "$(find backend/plugin -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' -exec basename {} \; | sort)" = config
```
> 注:slim 版仍保留 `backend/middleware/opera_log_middleware.py` 的 `OperaLogMiddleware`,用于控制台访问日志;检测 DB 日志残留时不要把该中间件类名视为问题。
### 高冲突文件
以下文件在完整版更新时最容易产生冲突:
1. **`backend/core/conf.py`** — 配置字段差异最大
2. **`backend/core/registrar.py`** — 中间件和组件注册差异
3. **`backend/cli.py`** — 删除 Celery/代码生成命令,保留插件 `Add`/`Remove`/`Deps`
4. **`backend/main.py`** — 上游插件准备流程必须保留
5. **`backend/common/security/jwt.py`** — `GetUserInfoDetail` vs `GetUserInfoWithRelationDetail``get_current_user()` 差异
6. **`backend/app/admin/crud/crud_user.py`** — 无 get_join/JoinConfig/m2m 操作
7. **`backend/app/admin/service/auth_service.py`** — 登录日志、menu_dao、background_tasks 差异
8. **`backend/middleware/opera_log_middleware.py`** — 完整版有 DB 队列,slim 版仅控制台
9. **`backend/middleware/jwt_auth_middleware.py`** — schema 类型差异
10. **`backend/plugin/config/sql/**`** — config 插件 SQL 不能再引用已删除的 `sys_menu`
11. **`pyproject.toml`** — 依赖列表差异
12. **`docker-compose.yml`** — 容器编排差异
13. **`Dockerfile`** — 构建阶段差异,需保留插件依赖预安装步骤但删除 Celery 多镜像阶段
14. **`deploy/backend/nginx.conf`** — Flower 代理容易残留
### 合并策略
1. **以上游新版本为基准**,只按本文档删除标准瘦身项,不要直接用旧 slim 文件覆盖上游正常演进
2. **需要手动合并**的文件:CRUD/Service/API 层(可能有新增功能需要保留,但需移除 RBAC/数据权限/可观测性引用)
3. **直接接受完整版**的文件:不涉及上述移除功能的纯业务逻辑改动
4. **合并后运行上述 `rg` 命令**清理残留引用
5. **插件依赖安装保持上游行为**`backend/main.py` 启动时检测并安装缺失插件依赖,Dockerfile 构建时预装随包插件依赖
6. **运行 `fba format` 和导入检查**确认格式、锁文件、导出依赖和导入无误
7. **插件 CLI 必须保留**:插件核心系统完整保留,因此 `fba add` / `fba remove` / `fba deps` 与插件安装 API 一并保留;不要再删除插件安装卸载命令
### 验证清单
瘦身完成后至少运行:
```bash
fba format
uv run python -c "from backend.main import app; print(app.title); print(sorted(app.openapi()['paths']))"
```
> FastAPI 0.141+ 将 `include_router` 存为嵌套 `_IncludedRouter``len(app.routes)` 不再等于接口数量;请用 OpenAPI paths 核对。精简版应只有 auth/user/file/plugin/config,不应出现 role/menu/dept/log/monitor/task 或 AI 插件路由。
+6 -23
View File
@@ -1,11 +1,14 @@
# Env
ENVIRONMENT='dev'
# Database
DATABASE_TYPE='mysql'
DATABASE_TYPE='postgresql'
DATABASE_HOST='127.0.0.1'
DATABASE_PORT=3306
DATABASE_USER='root'
DATABASE_PORT=5432
DATABASE_USER='postgres'
DATABASE_PASSWORD='123456'
# 额外数据源
# DATABASE_SOURCES='{"read": "postgresql+asyncpg://user:password@host:5432/fba"}'
DATABASE_SOURCES='{}'
# Redis
REDIS_HOST='127.0.0.1'
REDIS_PORT=6379
@@ -13,23 +16,3 @@ REDIS_PASSWORD=''
REDIS_DATABASE=0
# Token
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# [ App ] task
# Celery
CELERY_BROKER_REDIS_DATABASE=1
# Rabbitmq
CELERY_RABBITMQ_HOST='127.0.0.1'
CELERY_RABBITMQ_PORT=5672
CELERY_RABBITMQ_USERNAME='guest'
CELERY_RABBITMQ_PASSWORD='guest'
# [ Plugin ] oauth2
OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# [ Plugin ] email
EMAIL_USERNAME=''
EMAIL_PASSWORD=''
-55
View File
@@ -1,55 +0,0 @@
line-length = 120
cache-dir = ".ruff_cache"
target-version = "py310"
unsafe-fixes = true
show-fixes = true
[lint]
select = [
"E",
"F",
"I",
"TC",
# W
"W505",
# PT
"PT018",
# SIM
"SIM101",
"SIM114",
# PGH
"PGH004",
# PL
"PLE1142",
# RUF
"RUF100",
# UP
"UP007"
]
preview = true
ignore = ["FURB101"]
[lint.flake8-pytest-style]
mark-parentheses = false
parametrize-names-type = "list"
parametrize-values-row-type = "list"
parametrize-values-type = "tuple"
[lint.flake8-unused-arguments]
ignore-variadic-names = true
[lint.isort]
lines-between-types = 1
order-by-type = true
[lint.per-file-ignores]
"**/api/v1/*.py" = ["TC"]
"**/model/*.py" = ["TC003"]
"**/model/__init__.py" = ["F401"]
"**/tests/*.py" = ["E402"]
[format]
preview = true
quote-style = "single"
docstring-code-format = true
skip-magic-trailing-comma = false
-65
View File
@@ -16,68 +16,3 @@
```shell
docker run -d -p 8000:8000 --name fba_server fba_backend_independent
```
## Contributing
1. Prerequisites
- Python >= 3.10
- Git
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Fork this repository to your GitHub account
2. Installation and setup
Clone your forked repository:
```shell
git clone https://github.com/<your account>/fastapi_best_architecture.git
```
Go to the root directory of the project, open the terminal, and run the following command:
```sh
uv sync --frozen
```
3. Checkout
Checkout a new branch and make your changes
```shell
git checkout -b your-new-feature-branch
```
4. Format and Lint
Auto-formatting and lint via `pre-commit`
```shell
pre-commit run --all-files
```
5. Commit and push
Commit your changes and push your branch to the GitHub.
6. PR
Create a PR via GitHub
## Scripts
> [!WARNING]
>
> The following script may not apply to the Windows platform
>
> It is recommended to execute under the backend directory, and chmod authorization may be required
- `pre_start.sh`: Perform automatic database migration
- `celery-start.sh`: For celery docker script, implementation is not recommended
- `scripts/format.sh`: Perform ruff format check
- `scripts/lint.sh`: Perform pre-commit formatting
- `scripts/export.sh`: Execute uv export dependency package
+14 -6
View File
@@ -1,9 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.common.i18n import i18n
import sqlalchemy as sa
__version__ = '1.8.2'
from backend.utils.dynamic_import import get_all_models
# 初始化 i18n
i18n.load_locales()
def _register_model_globals() -> None:
"""导入所有模型并注册到 backend 模块命名空间"""
for model_obj in get_all_models():
model_name = model_obj.name if isinstance(model_obj, sa.Table) else model_obj.__name__
if model_name not in globals():
globals()[model_name] = model_obj
_register_model_globals()
__version__ = '1.15.1'
-81
View File
@@ -1,84 +1,3 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d_%%(minute).2d_%%(second).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
+19 -24
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ruff: noqa: F403, F401, I001, RUF100
import asyncio
import os
from logging.config import fileConfig
from alembic import context
@@ -10,41 +8,38 @@ from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from backend.app import get_app_models
from backend.common.model import MappedBase
from backend.core import path_conf
from backend.database.db import SQLALCHEMY_DATABASE_URL
from backend.plugin.tools import get_plugin_models
# import models
for cls in get_app_models() + get_plugin_models():
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
from backend.core.path_conf import BASE_PATH
from backend.database.db import get_database_url
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
alembic_config = context.config
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if alembic_config.config_file_name is not None:
fileConfig(alembic_config.config_file_name)
if config.config_file_name is not None:
fileConfig(BASE_PATH / config.config_file_name)
# model's MetaData object
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = MappedBase.metadata
# other values from the config, defined by the needs of env.py,
alembic_config.set_main_option(
'sqlalchemy.url', SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%')
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
config.set_main_option(
'sqlalchemy.url',
get_database_url().render_as_string(hide_password=False).replace('%', '%%'),
)
def run_migrations_offline():
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
@@ -56,7 +51,7 @@ def run_migrations_offline():
script output.
"""
url = alembic_config.get_main_option('sqlalchemy.url')
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
@@ -72,9 +67,9 @@ def run_migrations_offline():
def do_run_migrations(connection: Connection) -> None:
# 当迁移无变化时,不生成迁移记录
def process_revision_directives(context, revision, directives):
if alembic_config.cmd_opts.autogenerate:
def process_revision_directives(context, revision, directives) -> None: # ruff:ignore[missing-type-function-argument]
"""当迁移无变化时,不生成迁移记录"""
if config.cmd_opts.autogenerate:
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
@@ -100,7 +95,7 @@ async def run_async_migrations() -> None:
"""
connectable = async_engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}),
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os.path
from backend.core.path_conf import BASE_PATH
from backend.utils.import_parse import get_model_objects
def get_app_models() -> list[type]:
"""获取 app 所有模型类"""
app_path = os.path.join(BASE_PATH, 'app')
list_dirs = os.listdir(app_path)
apps = []
for d in list_dirs:
if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__':
apps.append(d)
objs = []
for app in apps:
module_path = f'backend.app.{app}.model'
obj = get_model_objects(module_path)
if obj:
objs.extend(obj)
return objs
# import all app models for auto create db tables
for cls in get_app_models():
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-6
View File
@@ -1,10 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router
from backend.app.admin.api.v1.log import router as log_router
from backend.app.admin.api.v1.monitor import router as monitor_router
from backend.app.admin.api.v1.sys import router as sys_router
from backend.core.conf import settings
@@ -12,5 +8,3 @@ v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
v1.include_router(auth_router)
v1.include_router(sys_router)
v1.include_router(log_router)
v1.include_router(monitor_router)
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.auth.auth import router as auth_router
+15 -19
View File
@@ -1,49 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from fastapi.security import HTTPBasicCredentials
from fastapi_limiter.depends import RateLimiter
from starlette.background import BackgroundTasks
from pyrate_limiter import Duration, Rate
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.auth_service import auth_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.utils.limiter import RateLimiter
router = APIRouter()
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
async def login_swagger(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(obj=obj)
return GetSwaggerToken(access_token=token, user=user)
async def login_swagger(
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(db=db, obj=obj)
return GetSwaggerToken(access_token=token, user=user) # type: ignore
@router.post(
'/login',
summary='用户登录',
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
dependencies=[Depends(RateLimiter(times=5, minutes=1))],
dependencies=[Depends(RateLimiter(Rate(5, Duration.MINUTE)))],
)
async def login(
request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
db: CurrentSessionTransaction,
response: Response,
obj: AuthLoginParam,
) -> ResponseSchemaModel[GetLoginToken]:
data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks)
data = await auth_service.login(db=db, response=response, obj=obj)
return response_base.success(data=data)
@router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_codes(request: Request) -> ResponseSchemaModel[list[str]]:
codes = await auth_service.get_codes(request=request)
return response_base.success(data=codes)
@router.post('/refresh', summary='刷新 token')
async def refresh_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(request=request)
async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(db=db, request=request, response=response)
return response_base.success(data=data)
+19 -16
View File
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from uuid import uuid4
import uuid
from fast_captcha import img_captcha
from fastapi import APIRouter, Depends, Request
from fastapi_limiter.depends import RateLimiter
from fastapi import APIRouter, Depends
from pyrate_limiter import Duration, Rate
from starlette.concurrency import run_in_threadpool
from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSession
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
from backend.utils.limiter import RateLimiter
router = APIRouter()
@@ -18,19 +19,21 @@ router = APIRouter()
@router.get(
'/captcha',
summary='获取登录验证码',
dependencies=[Depends(RateLimiter(times=5, seconds=10))],
dependencies=[Depends(RateLimiter(Rate(5, Duration.SECOND * 30)))],
)
async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]:
"""
此接口可能存在性能损耗尽管是异步接口但是验证码生成是IO密集型任务使用线程池尽量减少性能损耗
"""
img_type: str = 'base64'
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
uuid = str(uuid4())
async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
await load_login_config(db)
img, code = await run_in_threadpool(img_captcha, img_byte='base64')
captcha_uuid = str(uuid.uuid4())
await redis_client.set(
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
code,
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
ex=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
)
data = GetCaptchaDetail(
is_enabled=settings.LOGIN_CAPTCHA_ENABLED,
expire_seconds=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
uuid=captcha_uuid,
image=img,
)
data = GetCaptchaDetail(uuid=uuid, img_type=img_type, image=img)
return response_base.success(data=data)
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.log.login_log import router as login_log
from backend.app.admin.api.v1.log.opera_log import router as opera_log
router = APIRouter(prefix='/logs')
router.include_router(login_log, prefix='/login', tags=['登录日志'])
router.include_router(opera_log, prefix='/opera', tags=['操作日志'])
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
from backend.app.admin.service.login_log_service import login_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get(
'',
summary='分页获取登录日志',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_login_logs_paged(
db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
log_select = await login_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select)
return response_base.success(data=page_data)
@router.delete(
'',
summary='批量删除登录日志',
dependencies=[
Depends(RequestPermission('log:login:del')),
DependsRBAC,
],
)
async def delete_login_logs(obj: DeleteLoginLogParam) -> ResponseModel:
count = await login_log_service.delete(obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/all',
summary='清空登录日志',
dependencies=[
Depends(RequestPermission('log:login:clear')),
DependsRBAC,
],
)
async def delete_all_login_logs() -> ResponseModel:
await login_log_service.delete_all()
return response_base.success()
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
from backend.app.admin.service.opera_log_service import opera_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get(
'',
summary='分页获取操作日志',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_opera_logs_paged(
db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
log_select = await opera_log_service.get_select(username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select)
return response_base.success(data=page_data)
@router.delete(
'',
summary='批量删除操作日志',
dependencies=[
Depends(RequestPermission('log:opera:del')),
DependsRBAC,
],
)
async def delete_opera_logs(obj: DeleteOperaLogParam) -> ResponseModel:
count = await opera_log_service.delete(obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/all',
summary='清空操作日志',
dependencies=[
Depends(RequestPermission('log:opera:clear')),
DependsRBAC,
],
)
async def delete_all_opera_logs() -> ResponseModel:
await opera_log_service.delete_all()
return response_base.success()
@@ -1,13 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.monitor.online import router as token_router
from backend.app.admin.api.v1.monitor.redis import router as redis_router
from backend.app.admin.api.v1.monitor.server import router as server_router
router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, revoke_token, superuser_verify
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.core.conf import settings
from backend.database.redis import redis_client
router = APIRouter()
@router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
async def get_sessions(
username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
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:
token = await redis_client.get(key)
token_payload = jwt_decode(token)
user_id = token_payload.id
session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail(
id=user_id,
session_uuid=session_uuid,
username='未知',
nickname='未知',
ip='未知',
os='未知',
browser='未知',
device='未知',
status=StatusType.enable if session_uuid in online_clients else StatusType.disable,
last_login_time='未知',
expire_time=token_payload.expire_time,
)
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if extra_info:
extra_info = json.loads(extra_info)
# 排除 swagger 登录生成的 token
if extra_info.get('swagger') is None:
if username is not None:
if username == extra_info.get('username'):
append_token_detail()
else:
append_token_detail()
else:
data.append(token_detail)
return response_base.success(data=data)
@router.delete(
'/{pk}',
summary='强制下线',
dependencies=[
Depends(RequestPermission('sys:session:delete')),
DependsRBAC,
],
)
async def delete_session(
request: Request,
pk: Annotated[int, Path(description='用户 ID')],
session_uuid: Annotated[str, Query(description='会话 UUID')],
) -> ResponseModel:
superuser_verify(request)
await revoke_token(pk, session_uuid)
return response_base.success()
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.common.response.response_schema import ResponseModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.redis_info import redis_info
router = APIRouter()
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
async def get_redis_info() -> ResponseModel:
data = {
'info': await redis_info.get_info(),
'stats': await redis_info.get_stats(),
}
return response_base.success(data=data)
@@ -1,23 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from starlette.concurrency import run_in_threadpool
from backend.common.response.response_schema import ResponseModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.server_info import server_info
router = APIRouter()
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
async def get_server_info() -> ResponseModel:
data = {
# 扔到线程池,避免阻塞
'cpu': await run_in_threadpool(server_info.get_cpu_info),
'mem': await run_in_threadpool(server_info.get_mem_info),
'sys': await run_in_threadpool(server_info.get_sys_info),
'disk': await run_in_threadpool(server_info.get_disk_info),
'service': await run_in_threadpool(server_info.get_service_info),
}
return response_base.success(data=data)
+1 -13
View File
@@ -1,23 +1,11 @@
# !/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router
from backend.app.admin.api.v1.sys.dept import router as dept_router
from backend.app.admin.api.v1.sys.files import router as file_router
from backend.app.admin.api.v1.sys.menu import router as menu_router
from backend.app.admin.api.v1.sys.file import router as file_router
from backend.app.admin.api.v1.sys.plugin import router as plugin_router
from backend.app.admin.api.v1.sys.role import router as role_router
from backend.app.admin.api.v1.sys.user import router as user_router
router = APIRouter(prefix='/sys')
router.include_router(dept_router, prefix='/depts', tags=['系统部门'])
router.include_router(menu_router, prefix='/menus', tags=['系统菜单'])
router.include_router(role_router, prefix='/roles', tags=['系统角色'])
router.include_router(user_router, prefix='/users', tags=['系统用户'])
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(file_router, prefix='/files', tags=['系统文件'])
router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
GetDataRuleDetail,
UpdateDataRuleParam,
)
from backend.app.admin.service.data_rule_service import data_rule_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get('/models', summary='获取数据规则可用模型', dependencies=[DependsJwtAuth])
async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
models = await data_rule_service.get_models()
return response_base.success(data=models)
@router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
models = await data_rule_service.get_columns(model=model)
return response_base.success(data=models)
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data = await data_rule_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
async def get_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')],
) -> ResponseSchemaModel[GetDataRuleDetail]:
data = await data_rule_service.get(pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据规则',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_data_rules_paged(
db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
data_rule_select = await data_rule_service.get_select(name=name)
page_data = await paging_data(db, data_rule_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据规则',
dependencies=[
Depends(RequestPermission('data:rule:add')),
DependsRBAC,
],
)
async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
await data_rule_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据规则',
dependencies=[
Depends(RequestPermission('data:rule:edit')),
DependsRBAC,
],
)
async def update_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam
) -> ResponseModel:
count = await data_rule_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除数据规则',
dependencies=[
Depends(RequestPermission('data:rule:del')),
DependsRBAC,
],
)
async def delete_data_rules(obj: DeleteDataRuleParam) -> ResponseModel:
count = await data_rule_service.delete(obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
GetDataScopeDetail,
GetDataScopeWithRelationDetail,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.service.data_scope_service import data_scope_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
async def get_all_data_scope() -> ResponseSchemaModel[list[GetDataScopeDetail]]:
data = await data_scope_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeDetail]:
data = await data_scope_service.get(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
async def get_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
data = await data_scope_service.get_rules(pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有数据范围',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_data_scopes_paged(
db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
data_scope_select = await data_scope_service.get_select(name=name, status=status)
page_data = await paging_data(db, data_scope_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建数据范围',
dependencies=[
Depends(RequestPermission('data:scope:add')),
DependsRBAC,
],
)
async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
await data_scope_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新数据范围',
dependencies=[
Depends(RequestPermission('data:scope:edit')),
DependsRBAC,
],
)
async def update_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam
) -> ResponseModel:
count = await data_scope_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/rules',
summary='更新数据范围规则',
dependencies=[
Depends(RequestPermission('data:scope:rule:edit')),
DependsRBAC,
],
)
async def update_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam
):
count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除数据范围',
dependencies=[
Depends(RequestPermission('data:scope:del')),
DependsRBAC,
],
)
async def delete_data_scopes(obj: DeleteDataScopeParam) -> ResponseModel:
count = await data_scope_service.delete(obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
router = APIRouter()
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseSchemaModel[GetDeptDetail]:
data = await dept_service.get(pk=pk)
return response_base.success(data=data)
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
async def get_dept_tree(
request: Request,
name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(request=request, name=name, leader=leader, phone=phone, status=status)
return response_base.success(data=dept)
@router.post(
'',
summary='创建部门',
dependencies=[
Depends(RequestPermission('sys:dept:add')),
DependsRBAC,
],
)
async def create_dept(obj: CreateDeptParam) -> ResponseModel:
await dept_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新部门',
dependencies=[
Depends(RequestPermission('sys:dept:edit')),
DependsRBAC,
],
)
async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam) -> ResponseModel:
count = await dept_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/{pk}',
summary='删除部门',
dependencies=[
Depends(RequestPermission('sys:dept:del')),
DependsRBAC,
],
)
async def delete_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
count = await dept_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -1,13 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi import APIRouter, File, UploadFile
from backend.common.dataclasses import UploadUrl
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.file_ops import upload_file, upload_file_verify
router = APIRouter()
@@ -15,11 +12,8 @@ router = APIRouter()
@router.post(
'/upload',
summary='文件上传',
dependencies=[
Depends(RequestPermission('sys:file:upload')),
DependsRBAC,
],
summary='本地文件上传',
dependencies=[DependsJwtAuth],
)
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
upload_file_verify(file)
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
from backend.app.admin.service.menu_service import menu_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='已适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
menu = await menu_service.get_sidebar(request=request)
return response_base.success(data=menu)
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseSchemaModel[GetMenuDetail]:
data = await menu_service.get(pk=pk)
return response_base.success(data=data)
@router.get('', summary='获取菜单树', dependencies=[DependsJwtAuth])
async def get_menu_tree(
title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[GetMenuTree]]:
menu = await menu_service.get_tree(title=title, status=status)
return response_base.success(data=menu)
@router.post(
'',
summary='创建菜单',
dependencies=[
Depends(RequestPermission('sys:menu:add')),
DependsRBAC,
],
)
async def create_menu(obj: CreateMenuParam) -> ResponseModel:
await menu_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新菜单',
dependencies=[
Depends(RequestPermission('sys:menu:edit')),
DependsRBAC,
],
)
async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam) -> ResponseModel:
count = await menu_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'/{pk}',
summary='删除菜单',
dependencies=[
Depends(RequestPermission('sys:menu:del')),
DependsRBAC,
],
)
async def delete_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
count = await menu_service.delete(pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
+14 -30
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Path, UploadFile
from fastapi import APIRouter, File, Path, UploadFile
from fastapi.params import Query
from starlette.responses import StreamingResponse
@@ -10,20 +8,18 @@ from backend.app.admin.service.plugin_service import plugin_service
from backend.common.enums import PluginType
from backend.common.response.response_code import CustomResponse
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.common.security.jwt import DependsSuperUser
router = APIRouter()
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
@router.get('', summary='获取所有插件', dependencies=[DependsSuperUser])
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
plugins = await plugin_service.get_all()
return response_base.success(data=plugins)
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsJwtAuth])
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsSuperUser])
async def plugin_changed() -> ResponseSchemaModel[bool]:
plugins = await plugin_service.changed()
return response_base.success(data=bool(plugins))
@@ -32,11 +28,8 @@ async def plugin_changed() -> ResponseSchemaModel[bool]:
@router.post(
'',
summary='安装插件',
description='使用插件 zip 压缩包或 git 仓库地址进行安装',
dependencies=[
Depends(RequestPermission('sys:plugin:install')),
DependsRBAC,
],
description='使用插件 zip 压缩包或 git 仓库地址进行安装(仅开发环境)',
dependencies=[DependsSuperUser],
)
async def install_plugin(
type: Annotated[PluginType, Query(description='插件类型')],
@@ -46,41 +39,32 @@ async def install_plugin(
plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
return response_base.success(
res=CustomResponse(
code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务'
)
code=200,
msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务',
),
)
@router.delete(
'/{plugin}',
summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
dependencies=[
Depends(RequestPermission('sys:plugin:uninstall')),
DependsRBAC,
],
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录(仅开发环境)',
dependencies=[DependsSuperUser],
)
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin)
return response_base.success(
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务'),
)
@router.put(
'/{plugin}/status',
summary='更新插件状态',
dependencies=[
Depends(RequestPermission('sys:plugin:edit')),
DependsRBAC,
],
)
@router.put('/{plugin}/status', summary='更新插件状态', dependencies=[DependsSuperUser])
async def update_plugin_status(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.update_status(plugin=plugin)
return response_base.success()
@router.get('/{plugin}', summary='下载插件', dependencies=[DependsJwtAuth])
@router.get('/{plugin}', summary='下载插件', dependencies=[DependsSuperUser])
async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
bio = await plugin_service.build(plugin=plugin)
return StreamingResponse(
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.menu import GetMenuTree
from backend.app.admin.schema.role import (
CreateRoleParam,
DeleteRoleParam,
GetRoleDetail,
GetRoleWithRelationDetail,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.app.admin.service.role_service import role_service
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
router = APIRouter()
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_all()
return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
async def get_role_menu_tree(
pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[GetMenuTree] | None]:
menu = await role_service.get_menu_tree(pk=pk)
return response_base.success(data=menu)
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]:
rule = await role_service.get_scopes(pk=pk)
return response_base.success(data=rule)
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
async def get_role(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(pk=pk)
return response_base.success(data=data)
@router.get(
'',
summary='分页获取所有角色',
dependencies=[
DependsJwtAuth,
DependsPagination,
],
)
async def get_roles_paged(
db: CurrentSession,
name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
role_select = await role_service.get_select(name=name, status=status)
page_data = await paging_data(db, role_select)
return response_base.success(data=page_data)
@router.post(
'',
summary='创建角色',
dependencies=[
Depends(RequestPermission('sys:role:add')),
DependsRBAC,
],
)
async def create_role(obj: CreateRoleParam) -> ResponseModel:
await role_service.create(obj=obj)
return response_base.success()
@router.put(
'/{pk}',
summary='更新角色',
dependencies=[
Depends(RequestPermission('sys:role:edit')),
DependsRBAC,
],
)
async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam) -> ResponseModel:
count = await role_service.update(pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/menus',
summary='更新角色菜单',
dependencies=[
Depends(RequestPermission('sys:role:menu:edit')),
DependsRBAC,
],
)
async def update_role_menus(
pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam
) -> ResponseModel:
count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put(
'/{pk}/scopes',
summary='更新角色数据范围',
dependencies=[
Depends(RequestPermission('sys:role:scope:edit')),
DependsRBAC,
],
)
async def update_role_scopes(
pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam
) -> ResponseModel:
count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids)
if count > 0:
return response_base.success()
return response_base.fail()
@router.delete(
'',
summary='批量删除角色',
dependencies=[
Depends(RequestPermission('sys:role:del')),
DependsRBAC,
],
)
async def delete_roles(obj: DeleteRoleParam) -> ResponseModel:
count = await role_service.delete(obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
+44 -51
View File
@@ -1,46 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Request
from fastapi import APIRouter, Body, Path, Query, Request
from backend.app.admin.schema.role import GetRoleDetail
from backend.app.admin.schema.user import (
AddUserParam,
GetCurrentUserInfoWithRelationDetail,
GetUserInfoWithRelationDetail,
GetUserInfoDetail,
ResetPasswordParam,
UpdateUserParam,
)
from backend.app.admin.service.user_service import user_service
from backend.common.enums import UserPermissionType
from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession
from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
async def get_current_user(request: Request) -> ResponseSchemaModel[GetUserInfoDetail]:
data = request.user.model_dump()
return response_base.success(data=data)
@router.get('/{pk}', summary='获取用户信息', dependencies=[DependsJwtAuth])
async def get_userinfo(
db: CurrentSession,
pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
data = await user_service.get_userinfo(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def get_user_roles(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await user_service.get_roles(pk=pk)
) -> ResponseSchemaModel[GetUserInfoDetail]:
data = await user_service.get_userinfo(db=db, pk=pk)
return response_base.success(data=data)
@@ -52,62 +41,65 @@ async def get_user_roles(pk: Annotated[int, Path(description='用户 ID')]) -> R
DependsPagination,
],
)
async def get_users_paged(
async def get_users_paginated(
db: CurrentSession,
dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query(description='用户名')] = None,
phone: Annotated[str | None, Query(description='手机号')] = None,
status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status)
page_data = await paging_data(db, user_select)
) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]:
page_data = await user_service.get_list(db=db, username=username, phone=phone, status=status)
return response_base.success(data=page_data)
@router.post('', summary='创建用户', dependencies=[DependsRBAC])
async def create_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.create(request=request, obj=obj)
data = await user_service.get_userinfo(username=obj.username)
@router.post('', summary='创建用户', dependencies=[DependsSuperUser])
async def create_user(db: CurrentSessionTransaction, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoDetail]:
await user_service.create(db=db, obj=obj)
data = await user_service.get_userinfo(db=db, username=obj.username)
return response_base.success(data=data)
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsRBAC])
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsSuperUser])
async def update_user(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: UpdateUserParam
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='用户 ID')],
obj: UpdateUserParam,
) -> ResponseModel:
count = await user_service.update(request=request, pk=pk, obj=obj)
count = await user_service.update(db=db, pk=pk, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsRBAC])
@router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsSuperUser])
async def update_user_permission(
db: CurrentSessionTransaction,
request: Request,
pk: Annotated[int, Path(description='用户 ID')],
type: Annotated[UserPermissionType, Query(description='权限类型')],
) -> ResponseModel:
count = await user_service.update_permission(request=request, pk=pk, type=type)
count = await user_service.update_permission(db=db, request=request, pk=pk, type=type)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/me/password', summary='更新当前用户密码', dependencies=[DependsJwtAuth])
async def update_user_password(request: Request, obj: ResetPasswordParam) -> ResponseModel:
count = await user_service.update_password(request=request, obj=obj)
async def update_user_password(
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
) -> ResponseModel:
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsRBAC])
@router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsSuperUser])
async def reset_user_password(
request: Request,
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='用户 ID')],
password: Annotated[str, Body(embed=True, description='新密码')],
) -> ResponseModel:
count = await user_service.reset_password(request=request, pk=pk, password=password)
count = await user_service.reset_password(db=db, pk=pk, password=password)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -115,9 +107,11 @@ async def reset_user_password(
@router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth])
async def update_user_nickname(
request: Request, nickname: Annotated[str, Body(embed=True, description='用户昵称')]
db: CurrentSessionTransaction,
request: Request,
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
) -> ResponseModel:
count = await user_service.update_nickname(request=request, nickname=nickname)
count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -125,9 +119,11 @@ async def update_user_nickname(
@router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth])
async def update_user_avatar(
request: Request, avatar: Annotated[str, Body(embed=True, description='用户头像地址')]
db: CurrentSessionTransaction,
request: Request,
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
) -> ResponseModel:
count = await user_service.update_avatar(request=request, avatar=avatar)
count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -135,11 +131,11 @@ async def update_user_avatar(
@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth])
async def update_user_email(
db: CurrentSessionTransaction,
request: Request,
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
email: Annotated[str, Body(embed=True, description='用户邮箱')],
) -> ResponseModel:
count = await user_service.update_email(request=request, captcha=captcha, email=email)
count = await user_service.update_email(db=db, user_id=request.user.id, email=email)
if count > 0:
return response_base.success()
return response_base.fail()
@@ -148,13 +144,10 @@ async def update_user_email(
@router.delete(
path='/{pk}',
summary='删除用户',
dependencies=[
Depends(RequestPermission('sys:user:del')),
DependsRBAC,
],
dependencies=[DependsSuperUser],
)
async def delete_user(pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.delete(pk=pk)
async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.delete(db=db, pk=pk)
if count > 0:
return response_base.success()
return response_base.fail()
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-91
View File
@@ -1,91 +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.model import DataRule
from backend.app.admin.schema.data_rule import CreateDataRuleParam, UpdateDataRuleParam
class CRUDDataRule(CRUDPlus[DataRule]):
"""数据规则数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataRule | None:
"""
获取规则详情
:param db: 数据库会话
:param pk: 规则 ID
:return:
"""
return await self.select_model(db, pk)
async def get_list(self, name: str | None) -> Select:
"""
获取规则列表
:param name: 规则名称
:return:
"""
filters = {}
if name is not None:
filters['name__like'] = f'%{name}%'
return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
"""
通过名称获取规则
:param db: 数据库会话
:param name: 规则名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def get_all(self, db: AsyncSession) -> Sequence[DataRule]:
"""
获取所有规则
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def create(self, db: AsyncSession, obj: CreateDataRuleParam) -> None:
"""
创建规则
:param db: 数据库会话
:param obj: 创建规则参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新规则
:param db: 数据库会话
:param pk: 规则 ID
:param obj: 更新规则参数
:return:
"""
return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除规则
:param db: 数据库会话
:param pks: 规则 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam
class CRUDDataScope(CRUDPlus[DataScope]):
"""数据范围数据库操作类"""
async def get(self, db: AsyncSession, pk: int) -> DataScope | None:
"""
获取数据范围详情
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
return await self.select_model(db, pk)
async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None:
"""
通过名称获取数据范围
:param db: 数据库会话
:param name: 范围名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope:
"""
获取数据范围关联数据
:param db: 数据库会话
:param pk: 范围 ID
:return:
"""
return await self.select_model(db, pk, load_strategies=['rules'])
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
"""
获取所有数据范围
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_list(self, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表
:param name: 范围名称
:param status: 范围状态
:return:
"""
filters = {}
if name is not None:
filters['name__like'] = f'%{name}%'
if status is not None:
filters['status'] = status
return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters)
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param db: 数据库会话
:param obj: 创建数据范围参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param db: 数据库会话
:param pk: 范围 ID
:param obj: 更新数据范围参数
:return:
"""
return await self.update_model(db, pk, obj)
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID
:param rule_ids: 数据规则 ID 列表
:return:
"""
current_data_scope = await self.get_with_relation(db, pk)
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules))
rules = await db.execute(stmt)
current_data_scope.rules = rules.scalars().all()
return len(current_data_scope.rules)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除数据范围
:param db: 数据库会话
:param pks: 范围 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.security.permission import filter_data_permission
class CRUDDept(CRUDPlus[Dept]):
"""部门数据库操作类"""
async def get(self, db: AsyncSession, dept_id: int) -> Dept | None:
"""
获取部门详情
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.select_model_by_column(db, id=dept_id, del_flag=0)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
"""
通过名称获取部门
:param db: 数据库会话
:param name: 部门名称
:return:
"""
return await self.select_model_by_column(db, name=name, del_flag=0)
async def get_all(
self,
request: Request,
db: AsyncSession,
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> Sequence[Dept]:
"""
获取所有部门
:param request: FastAPI 请求对象
:param db: 数据库会话
:param name: 部门名称
:param leader: 负责人
:param phone: 联系电话
:param status: 部门状态
:return:
"""
filters = {'del_flag': 0}
if name is not None:
filters['name__like'] = f'%{name}%'
if leader is not None:
filters['leader__like'] = f'%{leader}%'
if phone is not None:
filters['phone__startswith'] = phone
if status is not None:
filters['status'] = status
data_filtered = await filter_data_permission(db, request)
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
"""
创建部门
:param db: 数据库会话
:param obj: 创建部门参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, dept_id: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param db: 数据库会话
:param dept_id: 部门 ID
:param obj: 更新部门参数
:return:
"""
return await self.update_model(db, dept_id, obj)
async def delete(self, db: AsyncSession, dept_id: int) -> int:
"""
删除部门
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
async def get_with_relation(self, db: AsyncSession, dept_id: int) -> Dept | None:
"""
获取部门及关联数据
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.select_model(db, dept_id, load_strategies=['users'])
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
"""
获取子部门列表
:param db: 数据库会话
:param dept_id: 部门 ID
:return:
"""
return await self.select_models(db, parent_id=dept_id, del_flag=0)
dept_dao: CRUDDept = CRUDDept(Dept)
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import LoginLog
from backend.app.admin.schema.login_log import CreateLoginLogParam
class CRUDLoginLog(CRUDPlus[LoginLog]):
"""登录日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取登录日志列表
:param username: 用户名
:param status: 登录状态
:param ip: IP 地址
:return:
"""
filters = {}
if username is not None:
filters['username__like'] = f'%{username}%'
if status is not None:
filters['status'] = status
if ip is not None:
filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
"""
创建登录日志
:param db: 数据库会话
:param obj: 创建登录日志参数
:return:
"""
await self.create_model(db, obj, commit=True)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除登录日志
:param db: 数据库会话
:param pks: 登录日志 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
@staticmethod
async def delete_all(db: AsyncSession) -> None:
"""
删除所有日志
:param db: 数据库会话
:return:
"""
await db.execute(sa_delete(LoginLog))
login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog)
-111
View File
@@ -1,111 +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.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
class CRUDMenu(CRUDPlus[Menu]):
"""菜单数据库操作类"""
async def get(self, db: AsyncSession, menu_id: int) -> Menu | None:
"""
获取菜单详情
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
return await self.select_model(db, menu_id)
async def get_by_title(self, db: AsyncSession, title: str) -> Menu | None:
"""
通过标题获取菜单
:param db: 数据库会话
:param title: 菜单标题
:return:
"""
return await self.select_model_by_column(db, title=title, type__ne=2)
async def get_all(self, db: AsyncSession, title: str | None, status: int | None) -> Sequence[Menu]:
"""
获取菜单列表
:param db: 数据库会话
:param title: 菜单标题
:param status: 菜单状态
:return:
"""
filters = {}
if title is not None:
filters['title__like'] = f'%{title}%'
if status is not None:
filters['status'] = status
return await self.select_models_order(db, 'sort', **filters)
async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
"""
获取用户的菜单侧边栏
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
filters = {'type__in': [0, 1, 3, 4]}
if menu_ids:
filters['id__in'] = menu_ids
return await self.select_models_order(db, 'sort', 'asc', **filters)
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
"""
创建菜单
:param db: 数据库会话
:param obj: 创建菜单参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, menu_id: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param db: 数据库会话
:param menu_id: 菜单 ID
:param obj: 更新菜单参数
:return:
"""
return await self.update_model(db, menu_id, obj)
async def delete(self, db: AsyncSession, menu_id: int) -> int:
"""
删除菜单
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
return await self.delete_model(db, menu_id)
async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]:
"""
获取子菜单列表
:param db: 数据库会话
:param menu_id: 菜单 ID
:return:
"""
menu = await self.select_model(db, menu_id, load_strategies=['children'])
return menu.children
menu_dao: CRUDMenu = CRUDMenu(Menu)
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import OperaLog
from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]):
"""操作日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select:
"""
获取操作日志列表
:param username: 用户名
:param status: 操作状态
:param ip: IP 地址
:return:
"""
filters = {}
if username is not None:
filters['username__like'] = f'%{username}%'
if status is not None:
filters['status__eq'] = status
if ip is not None:
filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param db: 数据库会话
:param obj: 操作日志创建参数
:return:
"""
await self.create_model(db, obj)
async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
"""
批量创建操作日志
:param db: 数据库会话
:param objs: 操作日志创建参数列表
:return:
"""
await self.create_models(db, objs)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除操作日志
:param db: 数据库会话
:param pks: 操作日志 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
@staticmethod
async def delete_all(db: AsyncSession) -> None:
"""
删除所有日志
:param db: 数据库会话
:return:
"""
await db.execute(sa_delete(OperaLog))
opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog)
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataScope, Menu, Role
from backend.app.admin.schema.role import (
CreateRoleParam,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
class CRUDRole(CRUDPlus[Role]):
"""角色数据库操作类"""
async def get(self, db: AsyncSession, role_id: int) -> Role | None:
"""
获取角色详情
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
return await self.select_model(db, role_id)
async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None:
"""
获取角色及关联数据
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
return await self.select_model(db, role_id, load_strategies=['menus', 'scopes'])
async def get_all(self, db: AsyncSession) -> Sequence[Role]:
"""
获取所有角色
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_list(self, name: str | None, status: int | None) -> Select:
"""
获取角色列表
:param name: 角色名称
:param status: 角色状态
:return:
"""
filters = {}
if name is not None:
filters['name__like'] = f'%{name}%'
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
load_strategies={
'users': 'noload',
'menus': 'noload',
'scopes': 'noload',
},
**filters,
)
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
"""
通过名称获取角色
:param db: 数据库会话
:param name: 角色名称
:return:
"""
return await self.select_model_by_column(db, name=name)
async def create(self, db: AsyncSession, obj: CreateRoleParam) -> None:
"""
创建角色
:param db: 数据库会话
:param obj: 创建角色参数
:return:
"""
await self.create_model(db, obj)
async def update(self, db: AsyncSession, role_id: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param db: 数据库会话
:param role_id: 角色 ID
:param obj: 更新角色参数
:return:
"""
return await self.update_model(db, role_id, obj)
async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
"""
更新角色菜单
:param db: 数据库会话
:param role_id: 角色 ID
:param menu_ids: 菜单 ID 列表
:return:
"""
current_role = await self.get_with_relation(db, role_id)
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus))
menus = await db.execute(stmt)
current_role.menus = menus.scalars().all()
return len(current_role.menus)
async def update_scopes(self, db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
"""
更新角色数据范围
:param db: 数据库会话
:param role_id: 角色 ID
:param scope_ids: 权限范围 ID 列表
:return:
"""
current_role = await self.get_with_relation(db, role_id)
stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes))
scopes = await db.execute(stmt)
current_role.scopes = scopes.scalars().all()
return len(current_role.scopes)
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
"""
批量删除角色
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_ids)
role_dao: CRUDRole = CRUDRole(Role)
+92 -130
View File
@@ -1,20 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from collections.abc import Sequence
import bcrypt
from sqlalchemy import select
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy.sql import Select
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept, Role, User
from backend.app.admin.schema.user import (
AddOAuth2UserParam,
AddUserParam,
UpdateUserParam,
)
from backend.common.security.jwt import get_hash_password
from backend.app.admin.model import User
from backend.app.admin.schema.user import AddUserParam, UpdateUserParam
from backend.app.admin.utils.password_security import get_hash_password
from backend.utils.timezone import timezone
@@ -29,7 +23,7 @@ class CRUDUser(CRUDPlus[User]):
:param user_id: 用户 ID
:return:
"""
return await self.select_model(db, user_id)
return await self.select_model(db, user_id, deleted=0)
async def get_by_username(self, db: AsyncSession, username: str) -> User | None:
"""
@@ -39,7 +33,17 @@ class CRUDUser(CRUDPlus[User]):
:param username: 用户名
:return:
"""
return await self.select_model_by_column(db, username=username)
return await self.select_model_by_column(db, username=username, deleted=0)
async def get_all_by_usernames(self, db: AsyncSession, usernames: list[str]) -> Sequence[User]:
"""
通过用户名列表批量获取用户
:param db: 数据库会话
:param usernames: 用户名列表
:return:
"""
return await self.select_models(db, username__in=usernames, deleted=0)
async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
"""
@@ -49,17 +53,37 @@ class CRUDUser(CRUDPlus[User]):
:param nickname: 用户昵称
:return:
"""
return await self.select_model_by_column(db, nickname=nickname)
return await self.select_model_by_column(db, nickname=nickname, deleted=0)
async def update_login_time(self, db: AsyncSession, username: str) -> int:
async def check_email(self, db: AsyncSession, email: str) -> User | None:
"""
更新用户最后登录时间
检查邮箱是否已被绑定
:param db: 数据库会话
:param username: 用户名
:param email: 电子邮箱
:return:
"""
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
return await self.select_model_by_column(db, email=email, deleted=0)
async def get_select(self, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表查询表达式
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {'deleted': 0}
if username:
filters['username__like'] = f'%{username}%'
if phone:
filters['phone__like'] = f'%{phone}%'
if status is not None:
filters['status'] = status
return await self.select_order('id', 'desc', **filters)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
"""
@@ -71,52 +95,47 @@ class CRUDUser(CRUDPlus[User]):
"""
salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump(exclude={'roles'})
dict_obj = obj.model_dump()
dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj)
stmt = select(Role).where(Role.id.in_(obj.roles))
roles = await db.execute(stmt)
new_user.roles = roles.scalars().all()
db.add(new_user)
await db.flush()
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
"""
通过 OAuth2 添加用户
:param db: 数据库会话
:param obj: 注册用户参数
:return:
"""
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
stmt = select(Role)
role = await db.execute(stmt)
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
db.add(new_user)
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param db: 数据库会话
:param input_user: 用户 ID
:param user_id: 用户 ID
:param obj: 更新用户参数
:return:
"""
role_ids = obj.roles
del obj.roles
count = await self.update_model(db, input_user.id, obj)
stmt = select(Role).where(Role.id.in_(role_ids))
roles = await db.execute(stmt)
input_user.roles = roles.scalars().all()
count = await self.update_model_by_column(db, obj, id=user_id, deleted=0)
return count
async def update_login_time(self, db: AsyncSession, username: str) -> int:
"""
更新用户上次登录时间
:param db: 数据库会话
:param username: 用户名
:return:
"""
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username, deleted=0)
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
"""
更新用户上次密码变更时间
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.update_model_by_column(
db, {'last_password_changed_time': timezone.now()}, id=user_id, deleted=0
)
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
"""
更新用户昵称
@@ -126,7 +145,7 @@ class CRUDUser(CRUDPlus[User]):
:param nickname: 用户昵称
:return:
"""
return await self.update_model(db, user_id, {'nickname': nickname})
return await self.update_model_by_column(db, {'nickname': nickname}, id=user_id, deleted=0)
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
"""
@@ -137,7 +156,7 @@ class CRUDUser(CRUDPlus[User]):
:param avatar: 头像地址
:return:
"""
return await self.update_model(db, user_id, {'avatar': avatar})
return await self.update_model_by_column(db, {'avatar': avatar}, id=user_id, deleted=0)
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
"""
@@ -148,27 +167,7 @@ class CRUDUser(CRUDPlus[User]):
:param email: 邮箱
:return:
"""
return await self.update_model(db, user_id, {'email': email})
async def delete(self, db: AsyncSession, user_id: int) -> int:
"""
删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.delete_model(db, user_id)
async def check_email(self, db: AsyncSession, email: str) -> User | None:
"""
检查邮箱是否已被绑定
:param db: 数据库会话
:param email: 电子邮箱
:return:
"""
return await self.select_model_by_column(db, email=email)
return await self.update_model_by_column(db, {'email': email}, id=user_id, deleted=0)
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
"""
@@ -181,40 +180,9 @@ class CRUDUser(CRUDPlus[User]):
"""
salt = bcrypt.gensalt()
new_pwd = get_hash_password(password, salt)
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt})
return await self.update_model_by_column(db, {'password': new_pwd, 'salt': salt}, flush=True, id=pk, deleted=0)
async def get_list(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {}
if dept:
filters['dept_id'] = dept
if username:
filters['username__like'] = f'%{username}%'
if phone:
filters['phone__like'] = f'%{phone}%'
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
'desc',
load_options=[
selectinload(self.model.dept).options(noload(Dept.parent), noload(Dept.children), noload(Dept.users)),
selectinload(self.model.roles).options(noload(Role.users), noload(Role.menus), noload(Role.scopes)),
],
**filters,
)
async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int:
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
"""
设置用户超级管理员状态
@@ -223,9 +191,9 @@ class CRUDUser(CRUDPlus[User]):
:param is_super: 是否超级管理员
:return:
"""
return await self.update_model(db, user_id, {'is_superuser': is_super})
return await self.update_model_by_column(db, {'is_superuser': is_super}, id=user_id, deleted=0)
async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int:
async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
"""
设置用户后台登录状态
@@ -234,7 +202,7 @@ class CRUDUser(CRUDPlus[User]):
:param is_staff: 是否可登录后台
:return:
"""
return await self.update_model(db, user_id, {'is_staff': is_staff})
return await self.update_model_by_column(db, {'is_staff': is_staff}, id=user_id, deleted=0)
async def set_status(self, db: AsyncSession, user_id: int, status: int) -> int:
"""
@@ -245,9 +213,9 @@ class CRUDUser(CRUDPlus[User]):
:param status: 状态
:return:
"""
return await self.update_model(db, user_id, {'status': status})
return await self.update_model_by_column(db, {'status': status}, id=user_id, deleted=0)
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int:
async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
"""
设置用户多端登录状态
@@ -256,31 +224,25 @@ class CRUDUser(CRUDPlus[User]):
:param multi_login: 是否允许多端登录
:return:
"""
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
return await self.update_model_by_column(db, {'is_multi_login': multi_login}, id=user_id, deleted=0)
async def get_with_relation(
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None
) -> User | None:
async def delete(self, db: AsyncSession, user_id: int) -> int:
"""
获取用户关联信息
删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:param username: 用户名
:return:
"""
filters = {}
if user_id:
filters['id'] = user_id
if username:
filters['username'] = username
return await self.select_model_by_column(
return await self.delete_model_by_column(
db,
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))],
load_strategies=['dept'],
**filters,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id=user_id,
deleted=0,
)
@@ -0,0 +1,34 @@
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model.user_password_history import UserPasswordHistory
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
"""用户密码历史记录数据库操作类"""
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
"""
创建密码历史记录
:param db: 数据库会话
:param obj: 创建密码历史记录参数
:return:
"""
await self.create_model(db, obj)
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
"""
获取用户的密码历史记录
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)
+2 -10
View File
@@ -1,10 +1,2 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.app.admin.model.data_rule import DataRule
from backend.app.admin.model.data_scope import DataScope
from backend.app.admin.model.dept import Dept
from backend.app.admin.model.login_log import LoginLog
from backend.app.admin.model.menu import Menu
from backend.app.admin.model.opera_log import OperaLog
from backend.app.admin.model.role import Role
from backend.app.admin.model.user import User
from backend.app.admin.model.user import User as User
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_data_scope_rule
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataScope
class DataRule(Base):
"""数据规则表"""
__tablename__ = 'sys_data_rule'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称')
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名')
column: Mapped[str] = mapped_column(String(20), comment='模型字段名')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in'
)
value: Mapped[str] = mapped_column(String(255), comment='规则值')
# 数据范围规则多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_data_scope_rule, sys_role_data_scope
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Role
class DataScope(Base):
"""数据范围表"""
__tablename__ = 'sys_data_scope'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则多对多
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='scopes')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class Dept(Base):
"""部门表"""
__tablename__ = 'sys_dept'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), comment='部门名称')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
leader: Mapped[str | None] = mapped_column(String(20), default=None, comment='负责人')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机')
email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱')
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
del_flag: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='删除标志(0删除 1存在)'
)
# 父级部门一对多
parent_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
)
parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
# 部门用户一对多
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone
class LoginLog(DataClassBase):
"""登录日志表"""
__tablename__ = 'sys_login_log'
id: Mapped[id_key] = mapped_column(init=False)
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID')
username: Mapped[str] = mapped_column(String(20), comment='用户名')
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
ip: Mapped[str] = mapped_column(String(50), comment='登录IP地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
)
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import BigInteger, Column, ForeignKey, Table
from backend.common.model import MappedBase
sys_user_role = Table(
'sys_user_role',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('user_id', BigInteger, ForeignKey('sys_user.id', ondelete='CASCADE'), primary_key=True, comment='用户ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
)
sys_role_menu = Table(
'sys_role_menu',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'),
Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'),
)
sys_role_data_scope = Table(
'sys_role_data_scope',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色 ID'),
Column(
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
)
sys_data_scope_rule = Table(
'sys_data_scope_rule',
MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column(
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
Column(
'data_rule_id',
BigInteger,
ForeignKey('sys_data_rule.id', ondelete='CASCADE'),
primary_key=True,
comment='数据规则 ID',
),
)
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_menu
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import Role
class Menu(Base):
"""菜单表"""
__tablename__ = 'sys_menu'
id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='菜单标题')
name: Mapped[str] = mapped_column(String(50), comment='菜单名称')
path: Mapped[str | None] = mapped_column(String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='外链地址'
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
)
# 父级菜单一对多
parent_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
)
parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
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')
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from sqlalchemy import String
from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone
class OperaLog(DataClassBase):
"""操作日志表"""
__tablename__ = 'sys_opera_log'
id: Mapped[id_key] = mapped_column(init=False)
trace_id: Mapped[str] = mapped_column(String(32), comment='请求跟踪 ID')
username: Mapped[str | None] = mapped_column(String(20), comment='用户名')
method: Mapped[str] = mapped_column(String(20), comment='请求类型')
title: Mapped[str] = mapped_column(String(255), comment='操作模块')
path: Mapped[str] = mapped_column(String(500), comment='请求路径')
ip: Mapped[str] = mapped_column(String(50), comment='IP地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市')
user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备')
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数')
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms')
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
)
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, String
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_role_data_scope, sys_role_menu, sys_user_role
from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataScope, Menu, User
class Role(Base):
"""角色表"""
__tablename__ = 'sys_role'
id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=True, comment='过滤数据权限(0否 1是)'
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注'
)
# 角色用户多对多
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')
# 角色数据范围多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
+22 -38
View File
@@ -1,57 +1,41 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.admin.model.m2m import sys_user_role
from backend.common.model import Base, TimeZone, id_key
from backend.database.db import uuid4_str
from backend.utils.timezone import timezone
if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role
class User(Base):
"""用户表"""
__tablename__ = 'sys_user'
__table_args__ = (
sa.UniqueConstraint('username', 'deleted', name='uk_sys_user_username_deleted'),
sa.UniqueConstraint('email', 'deleted', name='uk_sys_user_email_deleted'),
{'comment': '用户表'},
)
id: Mapped[id_key] = mapped_column(init=False)
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(String(20), comment='昵称')
password: Mapped[str | None] = mapped_column(String(255), comment='密码')
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐')
email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像')
uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(sa.String(64), index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(256), comment='加密盐')
email: Mapped[str | None] = mapped_column(sa.String(256), default=None, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_superuser: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)'
)
is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)'
)
is_multi_login: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)'
)
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
last_login_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
)
# 部门用户一对多
dept_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID'
last_password_changed_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
)
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
# 用户角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_user_role, back_populates='users')
@@ -0,0 +1,24 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone
class UserPasswordHistory(DataClassBase):
"""用户密码历史记录表"""
__tablename__ = 'sys_user_password_history'
id: Mapped[id_key] = mapped_column(init=False)
user_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='用户 ID')
password: Mapped[str] = mapped_column(sa.String(256), comment='历史密码')
created_time: Mapped[datetime] = mapped_column(
TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
)
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+2 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import Field
from backend.common.schema import SchemaBase
@@ -8,6 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase):
"""验证码详情"""
is_enabled: bool = Field(description='是否启用')
expire_seconds: int = Field(description='过期秒数')
uuid: str = Field(description='图片唯一标识')
img_type: str = Field(description='图片类型')
image: str = Field(description='图片内容')
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
from backend.common.schema import SchemaBase
class DataRuleSchemaBase(SchemaBase):
"""数据规则基础模型"""
name: str = Field(description='规则名称')
model: str = Field(description='模型名称')
column: str = Field(description='字段名称')
operator: RoleDataRuleOperatorType = Field(description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(description='表达式类型')
value: str = Field(description='规则值')
class CreateDataRuleParam(DataRuleSchemaBase):
"""创建数据规则参数"""
class UpdateDataRuleParam(DataRuleSchemaBase):
"""更新数据规则参数"""
class DeleteDataRuleParam(SchemaBase):
"""删除数据规则参数"""
pks: list[int] = Field(description='规则 ID 列表')
class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='规则 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情"""
key: str = Field(description='字段名')
comment: str = Field(description='字段评论')
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_rule import GetDataRuleDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class DataScopeBase(SchemaBase):
"""数据范围基础模型"""
name: str = Field(description='名称')
status: StatusType = Field(description='状态')
class CreateDataScopeParam(DataScopeBase):
"""创建数据范围参数"""
class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数"""
class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表')
class DeleteDataScopeParam(SchemaBase):
"""删除数据范围参数"""
pks: list[int] = Field(description='数据范围 ID 列表')
class GetDataScopeDetail(DataScopeBase):
"""数据范围详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='数据范围 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表')
-45
View File
@@ -1,45 +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 CustomEmailStr, CustomPhoneNumber, SchemaBase
class DeptSchemaBase(SchemaBase):
"""部门基础模型"""
name: str = Field(description='部门名称')
parent_id: int | None = Field(None, description='部门父级 ID')
sort: int = Field(0, ge=0, description='排序')
leader: str | None = Field(None, description='负责人')
phone: CustomPhoneNumber | None = Field(None, description='联系电话')
email: CustomEmailStr | None = Field(None, description='邮箱')
status: StatusType = Field(description='状态')
class CreateDeptParam(DeptSchemaBase):
"""创建部门参数"""
class UpdateDeptParam(DeptSchemaBase):
"""更新部门参数"""
class GetDeptDetail(DeptSchemaBase):
"""部门详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='部门 ID')
del_flag: bool = Field(description='是否删除')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetDeptTree(GetDeptDetail):
"""获取部门树"""
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.schema import SchemaBase
class LoginLogSchemaBase(SchemaBase):
"""登录日志基础模型"""
user_uuid: str = Field(description='用户 UUID')
username: str = Field(description='用户名')
status: int = Field(description='登录状态')
ip: str = Field(description='IP 地址')
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理')
browser: str | None = Field(None, description='浏览器')
os: str | None = Field(None, description='操作系统')
device: str | None = Field(None, description='设备')
msg: str = Field(description='消息')
login_time: datetime = Field(description='登录时间')
class CreateLoginLogParam(LoginLogSchemaBase):
"""创建登录日志参数"""
class UpdateLoginLogParam(LoginLogSchemaBase):
"""更新登录日志参数"""
class DeleteLoginLogParam(SchemaBase):
"""删除登录日志参数"""
pks: list[int] = Field(description='登录日志 ID 列表')
class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='日志 ID')
created_time: datetime = Field(description='创建时间')
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.common.enums import MenuType, StatusType
from backend.common.schema import SchemaBase
class MenuSchemaBase(SchemaBase):
"""菜单基础模型"""
title: str = Field(description='菜单标题')
name: str = Field(description='菜单名称')
path: str | None = Field(None, description='路由地址')
parent_id: int | None = Field(None, description='菜单父级 ID')
sort: int = Field(0, ge=0, description='排序')
icon: str | None = Field(None, description='图标')
type: MenuType = Field(description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: str | None = Field(None, description='组件路径')
perms: str | None = Field(None, description='权限标识')
status: StatusType = Field(description='状态')
display: StatusType = Field(description='是否显示')
cache: StatusType = Field(description='是否缓存')
link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注')
class CreateMenuParam(MenuSchemaBase):
"""创建菜单参数"""
class UpdateMenuParam(MenuSchemaBase):
"""更新菜单参数"""
class GetMenuDetail(MenuSchemaBase):
"""菜单详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='菜单 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetMenuTree(GetMenuDetail):
"""获取菜单树"""
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class OperaLogSchemaBase(SchemaBase):
"""操作日志基础模型"""
trace_id: str = Field(description='追踪 ID')
username: str | None = Field(None, description='用户名')
method: str = Field(description='请求方法')
title: str = Field(description='操作标题')
path: str = Field(description='请求路径')
ip: str = Field(description='IP 地址')
country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理')
os: str | None = Field(None, description='操作系统')
browser: str | None = Field(None, description='浏览器')
device: str | None = Field(None, description='设备')
args: dict[str, Any] | None = Field(None, description='请求参数')
status: StatusType = Field(description='状态')
code: str = Field(description='状态码')
msg: str | None = Field(None, description='消息')
cost_time: float = Field(description='耗时')
opera_time: datetime = Field(description='操作时间')
class CreateOperaLogParam(OperaLogSchemaBase):
"""创建操作日志参数"""
class UpdateOperaLogParam(OperaLogSchemaBase):
"""更新操作日志参数"""
class DeleteOperaLogParam(SchemaBase):
"""删除操作日志参数"""
pks: list[int] = Field(description='操作日志 ID 列表')
class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='日志 ID')
created_time: datetime = Field(description='创建时间')
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_scope import GetDataScopeDetail
from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase
class RoleSchemaBase(SchemaBase):
"""角色基础模型"""
name: str = Field(description='角色名称')
status: StatusType = Field(description='状态')
is_filter_scopes: bool = Field(True, description='过滤数据权限')
remark: str | None = Field(None, description='备注')
class CreateRoleParam(RoleSchemaBase):
"""创建角色参数"""
class UpdateRoleParam(RoleSchemaBase):
"""更新角色参数"""
class DeleteRoleParam(SchemaBase):
"""删除角色参数"""
pks: list[int] = Field(description='角色 ID 列表')
class UpdateRoleMenuParam(SchemaBase):
"""更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表')
class UpdateRoleScopeParam(SchemaBase):
"""更新角色数据范围参数"""
scopes: list[int] = Field(description='数据范围 ID 列表')
class GetRoleDetail(RoleSchemaBase):
"""角色详情"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='角色 ID')
created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间')
class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表')
+1 -2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import Field
@@ -32,6 +30,7 @@ class GetNewToken(AccessTokenBase):
class GetLoginToken(AccessTokenBase):
"""获取登录令牌"""
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
user: GetUserInfoDetail = Field(description='用户信息')
+6 -56
View File
@@ -1,15 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Any
from typing import Annotated
from pydantic import ConfigDict, Field, HttpUrl, model_validator
from typing_extensions import Self
from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer
from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
class AuthSchemaBase(SchemaBase):
@@ -22,8 +17,8 @@ class AuthSchemaBase(SchemaBase):
class AuthLoginParam(AuthSchemaBase):
"""用户登录参数"""
uuid: str = Field(description='验证码 UUID')
captcha: str = Field(description='验证码')
uuid: str | None = Field(None, description='验证码 UUID')
captcha: str | None = Field(None, description='验证码')
class AddUserParam(AuthSchemaBase):
@@ -32,17 +27,6 @@ class AddUserParam(AuthSchemaBase):
nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号码')
dept_id: int = Field(description='部门 ID')
roles: list[int] = Field(description='角色 ID 列表')
class AddOAuth2UserParam(AuthSchemaBase):
"""添加 OAuth2 用户参数"""
password: str | None = Field(None, description='密码')
nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱')
avatar: HttpUrl | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase):
@@ -56,10 +40,9 @@ class ResetPasswordParam(SchemaBase):
class UserInfoSchemaBase(SchemaBase):
"""用户信息基础模型"""
dept_id: int | None = Field(None, description='部门 ID')
username: str = Field(description='用户名')
nickname: str = Field(description='昵称')
avatar: HttpUrl | None = Field(None, description='头像地址')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号')
@@ -67,15 +50,12 @@ class UserInfoSchemaBase(SchemaBase):
class UpdateUserParam(UserInfoSchemaBase):
"""更新用户参数"""
roles: list[int] = Field(description='角色 ID 列表')
class GetUserInfoDetail(UserInfoSchemaBase):
"""用户信息详情"""
model_config = ConfigDict(from_attributes=True)
dept_id: int | None = Field(None, description='部门 ID')
id: int = Field(description='用户 ID')
uuid: str = Field(description='用户 UUID')
status: StatusType = Field(description='状态')
@@ -84,33 +64,3 @@ class GetUserInfoDetail(UserInfoSchemaBase):
is_multi_login: bool = Field(description='是否允许多端登录')
join_time: datetime = Field(description='加入时间')
last_login_time: datetime | None = Field(None, description='最后登录时间')
class GetUserInfoWithRelationDetail(GetUserInfoDetail):
"""用户信息关联详情"""
model_config = ConfigDict(from_attributes=True)
dept: GetDeptDetail | None = Field(None, description='部门信息')
roles: list[GetRoleWithRelationDetail] = Field(description='角色列表')
class GetCurrentUserInfoWithRelationDetail(GetUserInfoWithRelationDetail):
"""当前用户信息关联详情"""
model_config = ConfigDict(from_attributes=True)
dept: str | None = Field(None, description='部门名称')
roles: list[str] = Field(description='角色名称列表')
@model_validator(mode='before')
@classmethod
def handel(cls, data: Any) -> Self:
"""处理部门和角色数据"""
dept = data['dept']
if dept:
data['dept'] = dept['name']
roles = data['roles']
if roles:
data['roles'] = [role['name'] for role in roles]
return data
@@ -0,0 +1,14 @@
from pydantic import Field
from backend.common.schema import SchemaBase
class UserPasswordHistoryBase(SchemaBase):
"""用户历史密码记录基础模型"""
user_id: int = Field(description='用户 ID')
password: str = Field(description='历史密码')
class CreateUserPasswordHistoryParam(UserPasswordHistoryBase):
"""创建用户历史密码记录"""
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+128 -155
View File
@@ -1,17 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import Request, Response
from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken
from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.login_log_service import login_log_service
from backend.common.enums import LoginLogStatusType
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify
from backend.common.context import ctx
from backend.common.exception import errors
from backend.common.i18n import t
from backend.common.log import log
@@ -22,11 +19,10 @@ from backend.common.security.jwt import (
create_refresh_token,
get_token,
jwt_decode,
password_verify,
)
from backend.core.conf import settings
from backend.database.db import async_db_session, uuid4_str
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
from backend.utils.timezone import timezone
@@ -34,7 +30,7 @@ class AuthService:
"""认证服务类"""
@staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> User:
async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
"""
验证用户名和密码
@@ -47,188 +43,165 @@ class AuthService:
if not user:
raise errors.NotFoundError(msg='用户名或密码有误')
if user.password is None:
await password_security_service.check_status(user.id, user.status)
if user.password is None or not password_verify(password, user.password):
await password_security_service.handle_login_failure(db, user.id)
raise errors.AuthorizationError(msg='用户名或密码有误')
else:
if not password_verify(password, user.password):
raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
days_remaining = await password_security_service.check_password_expiry_status(
db, user.last_password_changed_time
)
return user
await password_security_service.handle_login_success(user.id)
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
return user, days_remaining
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
"""
Swagger 文档登录
:param db: 数据库会话
:param obj: 登录凭证
:return:
"""
async with async_db_session.begin() as db:
user = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username)
access_token = await create_access_token(
user.id,
user.is_multi_login,
# extra info
swagger=True,
)
return access_token.access_token, user
user, _ = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username)
access_token_data = await create_access_token(
user.id,
multi_login=user.is_multi_login,
# extra info
swagger=True,
)
return access_token_data.access_token, user
async def login(
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks
self,
*,
db: AsyncSession,
response: Response,
obj: AuthLoginParam,
) -> GetLoginToken:
"""
用户登录
:param request: 请求对象
:param db: 数据库会话
:param response: 响应对象
:param obj: 登录参数
:param background_tasks: 后台任务
:return:
"""
async with async_db_session.begin() as db:
user = None
try:
user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
user = None
try:
await load_login_config(db)
if settings.LOGIN_CAPTCHA_ENABLED:
if not obj.uuid or not obj.captcha:
raise errors.RequestError(msg=t('error.captcha.invalid'))
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
if not captcha_code:
raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
await user_dao.update_login_time(db, obj.username)
await db.refresh(user)
access_token = await create_access_token(
user.id,
user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.to_str(user.last_login_time),
ip=request.state.ip,
os=request.state.os,
browser=request.state.browser,
device=request.state.device,
)
refresh_token = await create_refresh_token(access_token.session_uuid, user.id, user.is_multi_login)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
httponly=True,
)
except errors.NotFoundError as e:
log.error('登陆错误: 用户名不存在')
raise errors.NotFoundError(msg=e.msg)
except (errors.RequestError, errors.CustomError) as e:
if not user:
log.error('登陆错误: 用户密码有误')
task = BackgroundTask(
login_log_service.create,
**dict(
db=db,
request=request,
user_uuid=user.uuid if user else uuid4_str(),
username=obj.username,
login_time=timezone.now(),
status=LoginLogStatusType.fail.value,
msg=e.msg,
),
)
raise errors.RequestError(code=e.code, msg=e.msg, background=task)
except Exception as e:
log.error(f'登陆错误: {e}')
raise e
else:
background_tasks.add_task(
login_log_service.create,
**dict(
db=db,
request=request,
user_uuid=user.uuid,
username=obj.username,
login_time=timezone.now(),
status=LoginLogStatusType.success.value,
msg=t('success.login.success'),
),
)
data = GetLoginToken(
access_token=access_token.access_token,
access_token_expire_time=access_token.access_token_expire_time,
session_uuid=access_token.session_uuid,
user=user, # type: ignore
)
return data
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
@staticmethod
async def get_codes(*, request: Request) -> list[str]:
"""
获取用户权限码
:param request: FastAPI 请求对象
:return:
"""
codes = set()
if request.user.is_superuser:
async with async_db_session.begin() as db:
menus = await menu_dao.get_all(db, None, None)
for menu in menus:
if menu.perms:
codes.add(*menu.perms.split(','))
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username)
await db.refresh(user)
access_token_data = await create_access_token(
user.id,
multi_login=user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.to_str(user.last_login_time),
ip=ctx.ip,
os=ctx.os,
browser=ctx.browser,
device=ctx.device,
)
refresh_token_data = await create_refresh_token(
access_token_data.session_uuid,
user.id,
multi_login=user.is_multi_login,
)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token_data.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
httponly=True,
)
except errors.NotFoundError as e:
log.error('登陆错误: 用户名不存在')
raise errors.NotFoundError(msg=e.msg)
except (errors.RequestError, errors.CustomError) as e:
if not user:
log.error(f'登陆错误: {e.msg}')
raise errors.RequestError(code=e.code, msg=e.msg)
except Exception as e:
log.error(f'登陆错误: {e}')
raise
else:
roles = request.user.roles
if roles:
for role in roles:
for menu in role.menus:
if menu.perms:
codes.add(*menu.perms.split(','))
return list(codes)
data = GetLoginToken(
access_token=access_token_data.access_token,
access_token_expire_time=access_token_data.access_token_expire_time,
session_uuid=access_token_data.session_uuid,
password_expire_days_remaining=days_remaining,
user=user, # type: ignore
)
return data
@staticmethod
async def refresh_token(*, request: Request) -> GetNewToken:
async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
"""
刷新令牌
:param db: 数据库会话
:param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:return:
"""
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if not refresh_token:
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
token_payload = jwt_decode(refresh_token)
async with async_db_session() as db:
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
elif not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
if not user.is_multi_login:
if await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token(
refresh_token,
token_payload.session_uuid,
user.id,
user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.to_str(user.last_login_time),
ip=request.state.ip,
os=request.state.os,
browser=request.state.browser,
device_type=request.state.device,
)
data = GetNewToken(
access_token=new_token.new_access_token,
access_token_expire_time=new_token.new_access_token_expire_time,
session_uuid=new_token.session_uuid,
)
return data
user = await user_dao.get(db, token_payload.user_id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
token_keys = await redis_client.get_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
if not user.is_multi_login and [
key for key in token_keys if not key.endswith(f':{token_payload.session_uuid}')
]:
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token(
refresh_token,
token_payload.session_uuid,
user.id,
multi_login=user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.to_str(user.last_login_time),
ip=ctx.ip,
os=ctx.os,
browser=ctx.browser,
device_type=ctx.device,
)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=new_token.new_refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
httponly=True,
)
data = GetNewToken(
access_token=new_token.new_access_token,
access_token_expire_time=new_token.new_access_token_expire_time,
session_uuid=new_token.session_uuid,
)
return data
@staticmethod
async def logout(*, request: Request, response: Response) -> None:
@@ -242,7 +215,7 @@ class AuthService:
try:
token = get_token(request)
token_payload = jwt_decode(token)
user_id = token_payload.id
user_id = token_payload.user_id
session_uuid = token_payload.session_uuid
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError:
@@ -253,7 +226,7 @@ class AuthService:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if refresh_token:
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
auth_service: AuthService = AuthService()
@@ -1,125 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select
from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
UpdateDataRuleParam,
)
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.utils.import_parse import dynamic_import_data_model
class DataRuleService:
"""数据规则服务类"""
@staticmethod
async def get(*, pk: int) -> DataRule:
"""
获取数据规则详情
:param pk: 规则 ID
:return:
"""
async with async_db_session() as db:
data_rule = await data_rule_dao.get(db, pk)
if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在')
return data_rule
@staticmethod
async def get_models() -> list[str]:
"""获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys())
@staticmethod
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
"""
获取数据规则可用模型的字段列表
:param model: 模型名称
:return:
"""
if model not in settings.DATA_PERMISSION_MODELS:
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model])
model_columns = [
GetDataRuleColumnDetail(key=column.key, comment=column.comment)
for column in model_ins.__table__.columns
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
]
return model_columns
@staticmethod
async def get_select(*, name: str | None) -> Select:
"""
获取数据规则列表查询条件
:param name: 规则名称
:return:
"""
return await data_rule_dao.get_list(name=name)
@staticmethod
async def get_all() -> Sequence[DataRule]:
"""获取所有数据规则"""
async with async_db_session() as db:
data_rules = await data_rule_dao.get_all(db)
return data_rules
@staticmethod
async def create(*, obj: CreateDataRuleParam) -> None:
"""
创建数据规则
:param obj: 规则创建参数
:return:
"""
async with async_db_session.begin() as db:
data_rule = await data_rule_dao.get_by_name(db, obj.name)
if data_rule:
raise errors.ConflictError(msg='数据规则已存在')
await data_rule_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateDataRuleParam) -> int:
"""
更新数据规则
:param pk: 规则 ID
:param obj: 规则更新参数
:return:
"""
async with async_db_session.begin() as db:
data_rule = await data_rule_dao.get(db, pk)
if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name:
if await data_rule_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj)
return count
@staticmethod
async def delete(*, obj: DeleteDataRuleParam) -> int:
"""
批量删除数据规则
:param obj: 规则 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await data_rule_dao.delete(db, obj.pks)
return count
data_rule_service: DataRuleService = DataRuleService()
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.model import DataScope
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
class DataScopeService:
"""数据范围服务类"""
@staticmethod
async def get(*, pk: int) -> DataScope:
"""
获取数据范围详情
:param pk: 范围 ID
:return:
"""
async with async_db_session() as db:
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_all() -> Sequence[DataScope]:
"""获取所有数据范围"""
async with async_db_session() as db:
data_scopes = await data_scope_dao.get_all(db)
return data_scopes
@staticmethod
async def get_rules(*, pk: int) -> DataScope:
"""
获取数据范围规则
:param pk: 范围 ID
:return:
"""
async with async_db_session() as db:
data_scope = await data_scope_dao.get_with_relation(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
return data_scope
@staticmethod
async def get_select(*, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表查询条件
:param name: 范围名称
:param status: 范围状态
:return:
"""
return await data_scope_dao.get_list(name, status)
@staticmethod
async def create(*, obj: CreateDataScopeParam) -> None:
"""
创建数据范围
:param obj: 数据范围参数
:return:
"""
async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get_by_name(db, obj.name)
if data_scope:
raise errors.ConflictError(msg='数据范围已存在')
await data_scope_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateDataScopeParam) -> int:
"""
更新数据范围
:param pk: 范围 ID
:param obj: 数据范围更新参数
:return:
"""
async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name:
if await data_scope_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.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
@staticmethod
async def update_data_scope_rule(*, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
"""
更新数据范围规则
:param pk: 范围 ID
:param rule_ids: 规则 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await data_scope_dao.update_rules(db, pk, rule_ids)
return count
@staticmethod
async def delete(*, obj: DeleteDataScopeParam) -> int:
"""
批量删除数据范围
:param obj: 范围 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await data_scope_dao.delete(db, obj.pks)
for pk in obj.pks:
data_rule = await data_scope_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
data_scope_service: DataScopeService = DataScopeService()
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any
from fastapi import Request
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data
class DeptService:
"""部门服务类"""
@staticmethod
async def get(*, pk: int) -> Dept:
"""
获取部门详情
:param pk: 部门 ID
:return:
"""
async with async_db_session() as db:
dept = await dept_dao.get(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
return dept
@staticmethod
async def get_tree(
*, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None
) -> list[dict[str, Any]]:
"""
获取部门树形结构
:param request: FastAPI 请求对象
:param name: 部门名称
:param leader: 部门负责人
:param phone: 联系电话
:param status: 状态
:return:
"""
async with async_db_session() as db:
dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
tree_data = get_tree_data(dept_select)
return tree_data
@staticmethod
async def create(*, obj: CreateDeptParam) -> None:
"""
创建部门
:param obj: 部门创建参数
:return:
"""
async with async_db_session.begin() as db:
dept = await dept_dao.get_by_name(db, obj.name)
if dept:
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在')
await dept_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateDeptParam) -> int:
"""
更新部门
:param pk: 部门 ID
:param obj: 部门更新参数
:return:
"""
async with async_db_session.begin() as db:
dept = await dept_dao.get(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
if dept.name != obj.name:
if await dept_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在')
if obj.parent_id == dept.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await dept_dao.update(db, pk, obj)
return count
@staticmethod
async def delete(*, pk: int) -> int:
"""
删除部门
:param pk: 部门 ID
:return:
"""
async with async_db_session.begin() as db:
dept = await dept_dao.get_with_relation(db, pk)
if dept.users:
raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk)
if children:
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk)
for user in dept.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
dept_service: DeptService = DeptService()
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
from fastapi import Request
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_login_log import login_log_dao
from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
from backend.common.log import log
from backend.database.db import async_db_session
class LoginLogService:
"""登录日志服务类"""
@staticmethod
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)
@staticmethod
async def create(
*,
db: AsyncSession,
request: Request,
user_uuid: str,
username: str,
login_time: datetime,
status: int,
msg: str,
) -> None:
"""
创建登录日志
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_uuid: 用户 UUID
:param username: 用户名
:param login_time: 登录时间
:param status: 状态
:param msg: 消息
:return:
"""
try:
obj = CreateLoginLogParam(
user_uuid=user_uuid,
username=username,
status=status,
ip=request.state.ip,
country=request.state.country,
region=request.state.region,
city=request.state.city,
user_agent=request.state.user_agent,
browser=request.state.browser,
os=request.state.os,
device=request.state.device,
msg=msg,
login_time=login_time,
)
await login_log_dao.create(db, obj)
except Exception as e:
log.error(f'登录日志创建失败: {e}')
@staticmethod
async def delete(*, obj: DeleteLoginLogParam) -> int:
"""
批量删除登录日志
:param obj: 日志 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await login_log_dao.delete(db, obj.pks)
return count
@staticmethod
async def delete_all() -> None:
"""清空所有登录日志"""
async with async_db_session.begin() as db:
await login_log_dao.delete_all(db)
login_log_service: LoginLogService = LoginLogService()
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any
from fastapi import Request
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
class MenuService:
"""菜单服务类"""
@staticmethod
async def get(*, pk: int) -> Menu:
"""
获取菜单详情
:param pk: 菜单 ID
:return:
"""
async with async_db_session() as db:
menu = await menu_dao.get(db, menu_id=pk)
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
return menu
@staticmethod
async def get_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]:
"""
获取菜单树形结构
:param title: 菜单标题
:param status: 状态
:return:
"""
async with async_db_session() as db:
menu_data = await menu_dao.get_all(db, title=title, status=status)
menu_tree = get_tree_data(menu_data)
return menu_tree
@staticmethod
async def get_sidebar(*, request: Request) -> list[dict[str, Any] | None]:
"""
获取用户的菜单侧边栏
:param request: FastAPI 请求对象
:return:
"""
async with async_db_session() as db:
if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None)
else:
roles = request.user.roles
menu_ids = set()
if roles:
for role in roles:
for menu in role.menus:
menu_ids.add(menu.id)
menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
menu_tree = get_vben5_tree_data(menu_data)
return menu_tree
@staticmethod
async def create(*, obj: CreateMenuParam) -> None:
"""
创建菜单
:param obj: 菜单创建参数
:return:
"""
async with async_db_session.begin() as db:
title = await menu_dao.get_by_title(db, obj.title)
if title:
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
raise errors.NotFoundError(msg='父级菜单不存在')
await menu_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateMenuParam) -> int:
"""
更新菜单
:param pk: 菜单 ID
:param obj: 菜单更新参数
:return:
"""
async with async_db_session.begin() as db:
menu = await menu_dao.get(db, pk)
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title:
if await menu_dao.get_by_title(db, obj.title):
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
raise errors.NotFoundError(msg='父级菜单不存在')
if obj.parent_id == menu.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await menu_dao.update(db, pk, obj)
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
@staticmethod
async def delete(*, pk: int) -> int:
"""
删除菜单
:param pk: 菜单 ID
:return:
"""
async with async_db_session.begin() as db:
children = await menu_dao.get_children(db, pk)
if children:
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk)
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
menu_service: MenuService = MenuService()
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select
from backend.app.admin.crud.crud_opera_log import opera_log_dao
from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
from backend.database.db import async_db_session
class OperaLogService:
"""操作日志服务类"""
@staticmethod
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)
@staticmethod
async def create(*, obj: CreateOperaLogParam) -> None:
"""
创建操作日志
:param obj: 操作日志创建参数
:return:
"""
async with async_db_session.begin() as db:
await opera_log_dao.create(db, obj)
@staticmethod
async def bulk_create(*, objs: list[CreateOperaLogParam]) -> None:
"""
批量创建操作日志
:param objs: 操作日志创建参数列表
:return:
"""
async with async_db_session.begin() as db:
await opera_log_dao.bulk_create(db, objs)
@staticmethod
async def delete(*, obj: DeleteOperaLogParam) -> int:
"""
批量删除操作日志
:param obj: 日志 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await opera_log_dao.delete(db, obj.pks)
return count
@staticmethod
async def delete_all() -> None:
"""清空所有操作日志"""
async with async_db_session.begin() as db:
await opera_log_dao.delete_all(db)
opera_log_service: OperaLogService = OperaLogService()
+40 -32
View File
@@ -1,22 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import json
import os
import shutil
import zipfile
from typing import Any
import anyio
from fastapi import UploadFile
from starlette.concurrency import run_in_threadpool
from backend.common.enums import PluginType, StatusType
from backend.common.exception import errors
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client
from backend.plugin.tools import uninstall_requirements_async
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
from backend.plugin.core import get_required_plugins
from backend.plugin.installer import install_git_plugin, install_zip_plugin, remove_plugin, zip_plugin
from backend.plugin.requirements import uninstall_requirements_async
from backend.utils.timezone import timezone
@@ -26,14 +25,21 @@ class PluginService:
@staticmethod
async def get_all() -> list[dict[str, Any]]:
"""获取所有插件"""
keys = []
changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
keys = [key for key in await redis_client.get_by_prefix(settings.PLUGIN_REDIS_PREFIX) if key != changed_key]
if not keys:
return []
result = []
plugin_infos = await redis_client.mget(*keys)
for info in plugin_infos:
if info is None:
continue
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*'):
keys.append(key)
for info in await redis_client.mget(*keys):
result.append(json.loads(info))
plugin_info = json.loads(info)
if isinstance(plugin_info, dict):
result.append(plugin_info)
return result
@@ -52,6 +58,8 @@ class PluginService:
:param repo_url: git 仓库地址
:return:
"""
if settings.ENVIRONMENT != 'dev':
raise errors.RequestError(msg='禁止在非开发环境下安装插件')
if type == PluginType.zip:
if not file:
raise errors.RequestError(msg='ZIP 压缩包不能为空')
@@ -61,31 +69,37 @@ class PluginService:
return await install_git_plugin(repo_url)
@staticmethod
async def uninstall(*, plugin: str):
async def uninstall(*, plugin: str) -> None:
"""
卸载插件
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
if settings.ENVIRONMENT != 'dev':
raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
if plugin in get_required_plugins():
raise errors.RequestError(msg=f'插件 {plugin} 为必需插件,禁止卸载')
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
shutil.move(plugin_dir, bacup_dir)
backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip'
await run_in_threadpool(zip_plugin, plugin_dir, backup_file)
await run_in_threadpool(remove_plugin, plugin_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
@staticmethod
async def update_status(*, plugin: str):
async def update_status(*, plugin: str) -> None:
"""
更新插件状态
:param plugin: 插件名称
:return:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
plugin_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
plugin_info = await redis_client.get(plugin_key)
if not plugin_info:
raise errors.NotFoundError(msg='插件不存在')
plugin_info = json.loads(plugin_info)
@@ -97,7 +111,8 @@ class PluginService:
else str(StatusType.disable.value)
)
plugin_info['plugin']['enable'] = new_status
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}', json.dumps(plugin_info, ensure_ascii=False))
await redis_client.set(plugin_key, json.dumps(plugin_info, ensure_ascii=False))
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
@staticmethod
async def build(*, plugin: str) -> io.BytesIO:
@@ -107,19 +122,12 @@ class PluginService:
:param plugin: 插件名称
:return:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
for root, dirs, files in os.walk(plugin_dir):
dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir)
zf.write(file_path, os.path.join(plugin, arcname))
await run_in_threadpool(zip_plugin, plugin_dir, bio)
bio.seek(0)
return bio
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any, Sequence
from sqlalchemy import Select
from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.model import Role
from backend.app.admin.schema.role import (
CreateRoleParam,
DeleteRoleParam,
UpdateRoleMenuParam,
UpdateRoleParam,
UpdateRoleScopeParam,
)
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data
class RoleService:
"""角色服务类"""
@staticmethod
async def get(*, pk: int) -> Role:
"""
获取角色详情
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
return role
@staticmethod
async def get_all() -> Sequence[Role]:
"""获取所有角色"""
async with async_db_session() as db:
roles = await role_dao.get_all(db)
return roles
@staticmethod
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)
@staticmethod
async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]:
"""
获取角色的菜单树形结构
:param pk: 角色 ID
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
menu_tree = get_tree_data(role.menus) if role.menus else []
return menu_tree
@staticmethod
async def get_scopes(*, pk: int) -> list[int]:
"""
获取角色数据范围列表
:param pk:
:return:
"""
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes]
return scope_ids
@staticmethod
async def create(*, obj: CreateRoleParam) -> None:
"""
创建角色
:param obj: 角色创建参数
:return:
"""
async with async_db_session.begin() as db:
role = await role_dao.get_by_name(db, obj.name)
if role:
raise errors.ConflictError(msg='角色已存在')
await role_dao.create(db, obj)
@staticmethod
async def update(*, pk: int, obj: UpdateRoleParam) -> int:
"""
更新角色
:param pk: 角色 ID
:param obj: 角色更新参数
:return:
"""
async with async_db_session.begin() as db:
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name:
if await role_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='角色已存在')
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
@staticmethod
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:
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
for menu_id in menu_ids.menus:
menu = await menu_dao.get(db, menu_id)
if not menu:
raise errors.NotFoundError(msg='菜单不存在')
count = await role_dao.update_menus(db, pk, menu_ids)
for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
"""
更新角色数据范围
:param pk: 角色 ID
:param scope_ids: 权限规则 ID 列表
:return:
"""
async with async_db_session.begin() as db:
role = await role_dao.get(db, pk)
if not role:
raise errors.NotFoundError(msg='角色不存在')
for scope_id in scope_ids.scopes:
scope = await data_scope_dao.get(db, scope_id)
if not scope:
raise errors.NotFoundError(msg='数据范围不存在')
count = await role_dao.update_scopes(db, pk, scope_ids)
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def delete(*, obj: DeleteRoleParam) -> int:
"""
批量删除角色
:param obj: 角色 ID 列表
:return:
"""
async with async_db_session.begin() as db:
count = await role_dao.delete(db, obj.pks)
for pk in obj.pks:
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
role_service: RoleService = RoleService()
@@ -0,0 +1,126 @@
import math
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.timezone import timezone
class UserPasswordHistoryService:
"""用户密码历史服务类"""
@staticmethod
async def check_status(user_id: int, user_status: int) -> None:
"""
检查用户状态
:param user_id: 用户 ID
:param user_status: 用户状态
:return:
"""
if not user_status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
if locked_until_str:
locked_until = timezone.from_str(locked_until_str)
now = timezone.now()
if locked_until > now:
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod
async def handle_login_failure(db: AsyncSession, user_id: int) -> None:
"""
处理登录失败
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
await load_user_security_config(db)
if settings.USER_LOCK_THRESHOLD == 0:
return
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
failure_count = int(failure_count) if failure_count else 0
failure_count += 1
await redis_client.set(
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
str(failure_count),
ex=settings.USER_LOCK_SECONDS,
)
if failure_count >= settings.USER_LOCK_THRESHOLD:
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
await redis_client.set(
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
timezone.to_str(locked_until),
ex=settings.USER_LOCK_SECONDS,
)
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
@staticmethod
async def check_password_expiry_status(db: AsyncSession, password_changed_time: datetime) -> int | None:
"""
检查密码过期状态
:param db: 数据库会话
:param password_changed_time: 密码修改时间
:return:
"""
await load_user_security_config(db)
if settings.USER_PASSWORD_EXPIRY_DAYS == 0:
return None
if not password_changed_time:
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
expiry_time = password_changed_time + timedelta(days=settings.USER_PASSWORD_EXPIRY_DAYS)
days_remaining = (expiry_time - timezone.now()).days
if days_remaining < 0:
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
if days_remaining <= settings.USER_PASSWORD_REMINDER_DAYS:
return days_remaining
return None
@staticmethod
async def handle_login_success(user_id: int) -> None:
"""
处理登录成功
:param user_id: 用户 ID
:return:
"""
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod
async def save_password_history(db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
"""
保存密码历史记录
:param db: 数据库会话
:param obj: 创建密码历史记录参数
:return:
"""
await user_password_history_dao.create(db, obj)
password_security_service: UserPasswordHistoryService = UserPasswordHistoryService()
+159 -205
View File
@@ -1,27 +1,23 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import random
from typing import Sequence
from typing import Any
from fastapi import Request
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import Role, User
from backend.app.admin.model import User
from backend.app.admin.schema.user import (
AddUserParam,
ResetPasswordParam,
UpdateUserParam,
)
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify, validate_new_password
from backend.common.enums import UserPermissionType
from backend.common.exception import errors
from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode, password_verify, superuser_verify
from backend.common.pagination import paging_data
from backend.common.security.jwt import get_token, jwt_decode
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
@@ -29,294 +25,252 @@ class UserService:
"""用户服务类"""
@staticmethod
async def get_userinfo(*, pk: int | None = None, username: str | None = None) -> User:
async def get_userinfo(*, db: AsyncSession, pk: int | None = None, username: str | None = None) -> User:
"""
获取用户信息
:param db: 数据库会话
:param pk: 用户 ID
:param username: 用户名
:return:
"""
async with async_db_session() as db:
user = await user_dao.get_with_relation(db, user_id=pk, username=username)
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user
user = await user_dao.get(db, pk) if pk else await user_dao.get_by_username(db, username or '')
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user
@staticmethod
async def get_roles(*, pk: int) -> Sequence[Role]:
async def get_list(*, db: AsyncSession, username: str, phone: str, status: int) -> dict[str, Any]:
"""
获取用户所有角色
获取用户列表
:param pk: 用户 ID
:return:
"""
async with async_db_session() as db:
user = await user_dao.get_with_relation(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
return user.roles
@staticmethod
async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select:
"""
获取用户列表查询条件
:param dept: 部门 ID
:param db: 数据库会话
:param username: 用户名
:param phone: 手机号
:param status: 状态
:return:
"""
return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status)
user_select = await user_dao.get_select(username=username, phone=phone, status=status)
return await paging_data(db, user_select)
@staticmethod
async def create(*, request: Request, obj: AddUserParam) -> None:
async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
"""
创建用户
:param request: FastAPI 请求对象
:param db: 数据库会话
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
if not obj.password:
raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
await user_dao.add(db, obj)
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
if obj.email and await user_dao.check_email(db, obj.email):
raise errors.ConflictError(msg='邮箱已被绑定')
if not obj.password:
raise errors.RequestError(msg='密码不允许为空')
obj.nickname = obj.nickname or obj.username
await user_dao.add(db, obj)
@staticmethod
async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int:
async def update(*, db: AsyncSession, pk: int, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param request: FastAPI 请求对象
:param db: 数据库会话
:param pk: 用户 ID
:param obj: 用户更新参数
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
user = await user_dao.get_with_relation(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username:
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
if obj.email and obj.email != user.email:
email_user = await user_dao.check_email(db, obj.email)
if email_user:
raise errors.ConflictError(msg='邮箱已被绑定')
count = await user_dao.update(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_permission(*, request: Request, pk: int, type: UserPermissionType) -> int:
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # ruff:ignore[complex-structure]
"""
更新用户权限
:param db: 数据库会话
:param request: FastAPI 请求对象
:param pk: 用户 ID
:param type: 权限类型
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
match type:
case UserPermissionType.superuser:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_super(db, pk, not user.status)
case UserPermissionType.staff:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_staff(db, pk, not user.is_staff)
case UserPermissionType.status:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
case UserPermissionType.multi_login:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, new_multi_login)
token = get_token(request)
token_payload = jwt_decode(token)
if pk == user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(
key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}'
)
else:
# 系统管理员修改他人时,他人 token 全部失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(key_prefix)
case _:
raise errors.RequestError(msg='权限类型不存在')
match type:
case UserPermissionType.superuser:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
case UserPermissionType.staff:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_staff(db, pk, is_staff=not user.is_staff)
case UserPermissionType.status:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
case UserPermissionType.multi_login:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
token = get_token(request)
token_payload = jwt_decode(token)
if pk == user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_by_prefix(
key_prefix,
exclude_keys=f'{key_prefix}:{token_payload.session_uuid}',
)
else:
# 系统管理员修改他人时,他人 token 全部失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_by_prefix(key_prefix)
case _:
raise errors.RequestError(msg='权限类型不存在')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def reset_password(*, request: Request, pk: int, password: str) -> int:
async def reset_password(*, db: AsyncSession, pk: int, password: str) -> int:
"""
重置用户密码
:param request: FastAPI 请求对象
:param db: 数据库会话
:param pk: 用户 ID
:param password: 新密码
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.reset_password(db, user.id, password)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
]
for prefix in key_prefix:
await redis_client.delete(prefix)
return count
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
await validate_new_password(db, user.id, password)
count = await user_dao.reset_password(db, user.id, password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_nickname(*, request: Request, nickname: str) -> int:
async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
"""
更新当前用户昵称
:param request: FastAPI 请求对象
:param db: 数据库会话
:param user_id: 用户 ID
:param nickname: 用户昵称
:return:
"""
async with async_db_session.begin() as db:
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_nickname(db, token_payload.id, nickname)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
count = await user_dao.update_nickname(db, user_id, nickname)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_avatar(*, request: Request, avatar: str) -> int:
async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
"""
更新当前用户头像
:param request: FastAPI 请求对象
:param db: 数据库会话
:param user_id: 用户 ID
:param avatar: 头像地址
:return:
"""
async with async_db_session.begin() as db:
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.update_avatar(db, token_payload.id, avatar)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
count = await user_dao.update_avatar(db, user_id, avatar)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_email(*, request: Request, captcha: str, email: str) -> int:
async def update_email(*, db: AsyncSession, user_id: int, email: str) -> int:
"""
更新当前用户邮箱
:param request: FastAPI 请求对象
:param captcha: 邮箱验证码
:param db: 数据库会话
:param user_id: 用户 ID
:param email: 邮箱
:return:
"""
async with async_db_session.begin() as db:
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code:
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{request.state.ip}')
count = await user_dao.update_email(db, token_payload.id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
email_user = await user_dao.check_email(db, email)
if email_user and email_user.id != user_id:
raise errors.ConflictError(msg='邮箱已被绑定')
count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_password(*, request: Request, obj: ResetPasswordParam) -> int:
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
"""
更新当前用户密码
:param request: FastAPI 请求对象
:param db: 数据库会话
:param user_id: 用户 ID
:param obj: 密码重置参数
:return:
"""
async with async_db_session.begin() as db:
token = get_token(request)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='密码输入不一致')
count = await user_dao.reset_password(db, user.id, obj.new_password)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
user = await user_dao.get(db, user_id)
if user.password and not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='两次密码输入不一致')
await validate_new_password(db, user_id, obj.new_password)
count = await user_dao.reset_password(db, user_id, obj.new_password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def delete(*, pk: int) -> int:
async def delete(*, db: AsyncSession, pk: int) -> int:
"""
删除用户
:param db: 数据库会话
:param pk: 用户 ID
:return:
"""
async with async_db_session.begin() as db:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.delete(db, user.id)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
]
for key in key_prefix:
await redis_client.delete_prefix(key)
return count
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.delete(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
user_service: UserService = UserService()
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,9 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from starlette.testclient import TestClient
def test_logout(client: TestClient, token_headers: dict[str, str]) -> None:
response = client.post('/auth/logout', headers=token_headers)
assert response.status_code == 200
assert response.json()['code'] == 200
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python3
# -*- 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
TEST_SQLALCHEMY_DATABASE_URL = create_database_url(unittest=True)
_, async_test_db_session = create_async_engine_and_session(TEST_SQLALCHEMY_DATABASE_URL)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
"""session 生成器"""
async with async_test_db_session() as session:
yield session
View File
+17
View File
@@ -0,0 +1,17 @@
from collections.abc import Sequence
from backend.core.conf import settings
from backend.database.redis import redis_client
class UserCacheManager:
"""用户缓存管理"""
@staticmethod
async def clear(user_ids: Sequence[int]) -> None:
"""清理用户缓存"""
if user_ids:
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
user_cache_manager: UserCacheManager = UserCacheManager()
@@ -0,0 +1,68 @@
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.common.exception import errors
from backend.core.conf import settings
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
password_hash = PasswordHash((BcryptHasher(),))
def get_hash_password(password: str, salt: bytes | None) -> str:
"""
使用哈希算法加密密码
:param password: 密码
:param salt: 盐值
:return:
"""
return password_hash.hash(password, salt=salt)
def password_verify(plain_password: str, hashed_password: str) -> bool:
"""
密码验证
:param plain_password: 待验证的密码
:param hashed_password: 哈希密码
:return:
"""
return password_hash.verify(plain_password, hashed_password)
async def validate_new_password(db: AsyncSession, user_id: int, new_password: str) -> None:
"""
验证新密码
:param db: 数据库会话
:param user_id: 用户ID
:param new_password: 新密码
:return:
"""
await load_user_security_config(db)
if len(new_password) < settings.USER_PASSWORD_MIN_LENGTH:
raise errors.RequestError(msg=f'密码长度不能少于 {settings.USER_PASSWORD_MIN_LENGTH} 个字符')
if len(new_password) > settings.USER_PASSWORD_MAX_LENGTH:
raise errors.RequestError(msg=f'密码长度不能超过 {settings.USER_PASSWORD_MAX_LENGTH} 个字符')
if not is_has_number(new_password):
raise errors.RequestError(msg='密码必须包含数字')
if not is_has_letter(new_password):
raise errors.RequestError(msg='密码必须包含字母')
if settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR and not is_has_special_char(new_password):
raise errors.RequestError(msg='密码必须包含特殊字符(如:!@#$%')
password_history = await user_password_history_dao.get_by_user_id(db, user_id)
for hist in password_history[: settings.USER_PASSWORD_HISTORY_CHECK_COUNT]:
if password_verify(new_password, hist.password):
raise errors.RequestError(
msg=f'新密码不能与最近 {settings.USER_PASSWORD_HISTORY_CHECK_COUNT} 次使用的密码相同'
)
-4
View File
@@ -1,11 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1
from backend.app.task.api.router import v1 as task_v1
router = APIRouter()
router.include_router(admin_v1)
router.include_router(task_v1)
-28
View File
@@ -1,28 +0,0 @@
## 任务介绍
当前任务使用 Celery
实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225)
## 定时任务
`backend/app/task/tasks/beat.py` 文件内编写相关定时任务
### 简单任务
`backend/app/task/tasks/tasks.py` 文件内编写相关任务代码
### 层级任务
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
2. 新建目录后,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
## 消息代理
你可以通过 `CELERY_BROKER` 控制消息代理选择,它支持 redis 和 rabbitmq
对于本地调试,建议使用 redis
对于线上环境,强制使用 rabbitmq
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from backend.core.path_conf import BASE_PATH
from .actions import * # noqa: F403
# 导入项目根目录
sys.path.append(str(BASE_PATH.parent))
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app
from backend.common.socketio.server import sio
@sio.event
async def task_worker_status(sid, data):
"""任务 Worker 状态事件"""
worker = await run_in_threadpool(celery_app.control.ping)
await sio.emit('task_worker_status', worker, sid)

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