Compare commits

..
234 Commits
Author SHA1 Message Date
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
297 changed files with 25313 additions and 7222 deletions
+4
View File
@@ -3,7 +3,11 @@ __pycache__/
.github/ .github/
.idea/ .idea/
.vscode/ .vscode/
.cursor/
venv/ venv/
.venv/ .venv/
.ruff_cache/ .ruff_cache/
.pytest_cache/ .pytest_cache/
.claude/
.serena/
.logs/
+8 -4
View File
@@ -1,16 +1,18 @@
name: Release changelog name: Release changelog
on: on:
push: workflow_run:
tags: workflows: ['Check version']
- v* types:
- completed
jobs: jobs:
changelog: changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
ref: master ref: master
@@ -18,4 +20,6 @@ jobs:
with: with:
file: CHANGELOG.md file: CHANGELOG.md
pull_request: true pull_request: true
commit_summary_template: 'Update the changelog for %s'
args: -c
github_token: ${{ secrets.GH_TOKEN }} github_token: ${{ secrets.GH_TOKEN }}
+2 -2
View File
@@ -15,10 +15,10 @@ jobs:
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }}
+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
+6
View File
@@ -7,3 +7,9 @@ venv/
.python-version .python-version
.ruff_cache/ .ruff_cache/
.pytest_cache/ .pytest_cache/
.claude/
.serena/
.agents/
logs/
.superpowers/
superpowers/
+14 -2
View File
@@ -1,13 +1,25 @@
default_language_version:
python: '>= 3.10'
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v6.0.0
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-json
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
- repo: https://github.com/tombi-toml/tombi-pre-commit
rev: v0.9.25
hooks:
- id: tombi-lint
args: ["--offline"]
- id: tombi-format
args: ["--offline"]
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.14.0 rev: v0.15.12
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
@@ -17,7 +29,7 @@ repos:
- id: ruff-format - id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.0 rev: 0.11.8
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
-174
View File
@@ -1,174 +0,0 @@
line-length = 120
preview = true
fix = true
unsafe-fixes = true
show-fixes = true
required-version = ">=0.13.0"
[lint]
select = [
"FAST",
"ANN001",
"ANN201",
"ANN202",
"ANN204",
"ANN205",
"ANN206",
"ASYNC110",
"ASYNC116",
"ASYNC210",
"ASYNC212",
"ASYNC230",
"ASYNC240",
"ASYNC250",
"ASYNC251",
"S310",
"FBT001",
"FBT002",
"B002",
"B005",
"B006",
"B007",
"B008",
"B009",
"B010",
"B013",
"B014",
"B019",
"B020",
"B021",
"B024",
"B025",
"B026",
"B027",
"B039",
"COM",
"C402",
"C403",
"C404",
"C408",
"C410",
"C411",
"C414",
"C416",
"C417",
"C418",
"C419",
"C420",
"DTZ",
"EXE",
"ISC001",
"ISC002",
"ISC003",
"PIE",
"PYI009",
"PYI010",
"PYI011",
"PYI012",
"PYI013",
"PYI016",
"PYI017",
"PYI019",
"PYI020",
"PYI021",
"PYI024",
"PYI026",
"PYI030",
"PYI033",
"PYI034",
"PYI036",
"PYI041",
"PYI042",
"PYI055",
"PYI061",
"PYI062",
"PYI063",
"Q001",
"Q002",
"RSE102",
"RET501",
"RET505",
"RET506",
"RET507",
"RET508",
"SIM101",
"SIM102",
"SIM103",
"SIM107",
"SIM108",
"SIM109",
"SIM110",
"SIM114",
"SIM115",
"SIM201",
"SIM202",
"SIM210",
"SIM211",
"SIM212",
"SIM300",
"SIM401",
"SIM910",
"TID252",
"TC",
"FLY",
"I",
"C901",
"N",
"PERF",
"E",
"W",
"D404",
"D417",
"D419",
"F",
"PGH",
"PLC1901",
"UP",
"FURB",
"RUF",
"TRY",
]
ignore = [
"COM812",
"PGH003",
"RUF001",
"RUF002",
"RUF003",
"RUF006",
"RUF012",
"TRY400",
"TRY003",
"TRY301"
]
[lint.per-file-ignores]
"**/model/*.py" = ["TC003"]
"backend/common/socketio/server.py" = ["ANN001"]
"backend/common/exception/exception_handler.py" = ["ANN202","RUF029"]
[lint.flake8-pytest-style]
parametrize-names-type = "list"
parametrize-values-row-type = "list"
parametrize-values-type = "list"
[lint.flake8-quotes]
inline-quotes = "single"
[lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"]
[lint.flake8-unused-arguments]
ignore-variadic-names = true
[lint.isort]
case-sensitive = true
lines-between-types = 1
order-by-type = true
[lint.pylint]
allow-dunder-method-names = ["__tablename__", "__table_args__"]
[format]
docstring-code-format = true
preview = true
quote-style = "single"
+149
View File
@@ -0,0 +1,149 @@
{
"$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", "app"],
"additionalProperties": false,
"properties": {
"plugin": {
"type": "object",
"description": "Plugin metadata",
"required": ["summary", "version", "description", "author", "tags", "database"],
"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",
"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" }
]
}
},
"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"
}
}
}
}
}
}
+1271 -636
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
+44 -18
View File
@@ -1,5 +1,5 @@
# Select the image to build based on SERVER_TYPE, defaulting to fastapi_server, or docker-compose build args # Select the image to build based on SERVER_TYPE, defaulting to fba_server, or docker-compose build args
ARG SERVER_TYPE=fastapi_server ARG SERVER_TYPE=fba_server
# === Python environment from uv === # === Python environment from uv ===
FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.10-bookworm-slim AS builder
@@ -22,47 +22,73 @@ ENV UV_COMPILE_BYTECODE=1 \
# Install dependencies with cache # Install dependencies with cache
RUN --mount=type=cache,target=/root/.cache/uv \ 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
# Preinstall plugin dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
python -c "from backend.plugin.requirements import install_requirements; install_requirements(None)"
# === Runtime base server image === # === Runtime base server image ===
FROM python:3.10-slim AS base_server FROM python:3.10-slim-bookworm AS base_server
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \ && 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/* && rm -rf /var/lib/apt/lists/*
ADD https://astral.sh/uv/install.sh /uv-installer.sh
RUN sh /uv-installer.sh && rm /uv-installer.sh
ENV PATH="/root/.local/bin/:$PATH"
COPY --from=builder /fba /fba COPY --from=builder /fba /fba
COPY --from=builder /usr/local /usr/local COPY --from=builder /usr/local /usr/local
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /fba/backend
# === FastAPI server image === # === FastAPI server image ===
FROM base_server AS fastapi_server FROM base_server AS fba_server
COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/ COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fastapi_server RUN mkdir -p /var/log/fba
EXPOSE 8001 EXPOSE 8001
CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port","8000"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery server image === # === Celery Worker image ===
FROM base_server AS celery FROM base_server AS fba_celery_worker
COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/ COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/celery RUN mkdir -p /var/log/fba
RUN chmod +x celery-start.sh CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Beat image ===
FROM base_server AS fba_celery_beat
COPY deploy/backend/supervisor/fba_celery_beat.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# === Celery Flower image ===
FROM base_server AS fba_celery_flower
COPY deploy/backend/supervisor/fba_celery_flower.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fba
EXPOSE 8555 EXPOSE 8555
CMD ["./celery-start.sh"] CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
# Build image # Build image
FROM ${SERVER_TYPE} FROM ${SERVER_TYPE}
+5 -12
View File
@@ -23,15 +23,13 @@ English | [简体中文](./README.zh-CN.md)
</div> </div>
## Pseudo 3-tier architecture ## Three-tier architecture
The mvc architecture is a common design pattern in python web, but the 3-tier architecture is even more fascinating The MVC architecture is common in Python Web as a general design pattern, but the three-tier architecture is more
fascinating
In python web development, there is no common standard for the concept of 3-tier architecture, so we'll call it a However, please note that we do not have a traditional multi-app (microservices) directory structure (django,
pseudo 3-tier architecture here springBoot...). ), but a self-righteous directory structure that you can give it any twist if you don't like this model!
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 | | workflow | java | fastapi_best_architecture |
|----------------|----------------|---------------------------| |----------------|----------------|---------------------------|
@@ -65,11 +63,6 @@ the [official documentation](https://fastapi-practices.github.io/fastapi_best_ar
[Discord](https://wu-clan.github.io/homepage/) [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 ## License
This project is licensed by the terms of This project is licensed by the terms of
+3 -9
View File
@@ -23,13 +23,11 @@
</div> </div>
## 三层架构 ## 三层架构
mvc 架构作为常规设计模式,在 python web 中很常见,但是三层架构更令人着迷 mvc 架构作为常规设计模式,在 python web 中很常见,但是三层架构更令人着迷
在 python web 开发中,三层架构的概念并没有通用标准,所以这里我们称之为伪三层架构 但请注意,我们并没有传统的多 app (微服务)目录结构(django、springBoot...),而是自以为是的目录结构,如果您不喜欢这种模式,可以对其进行任意改造!
但请注意,我们并没有传统的多应用程序结构(django、springBoot...),如果您不喜欢这种模式,可以使用模板对其进行随意改造!
| 工作流程 | java | fastapi_best_architecture | | 工作流程 | java | fastapi_best_architecture |
|------|----------------|---------------------------| |------|----------------|---------------------------|
@@ -62,10 +60,6 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
[Discord](https://wu-clan.github.io/homepage/) [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) 许可证的条款进行许可 本项目由 [MIT](https://github.com/fastapi-practices/fastapi_best_architecture/blob/master/LICENSE) 许可证的条款进行许可
+3 -7
View File
@@ -1,10 +1,10 @@
# Env # Env
ENVIRONMENT='dev' ENVIRONMENT='dev'
# Database # Database
DATABASE_TYPE='mysql' DATABASE_TYPE='postgresql'
DATABASE_HOST='127.0.0.1' DATABASE_HOST='127.0.0.1'
DATABASE_PORT=3306 DATABASE_PORT=5432
DATABASE_USER='root' DATABASE_USER='postgres'
DATABASE_PASSWORD='123456' DATABASE_PASSWORD='123456'
# Redis # Redis
REDIS_HOST='127.0.0.1' REDIS_HOST='127.0.0.1'
@@ -13,8 +13,6 @@ REDIS_PASSWORD=''
REDIS_DATABASE=0 REDIS_DATABASE=0
# Token # Token
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# [ App ] task # [ App ] task
# Celery # Celery
CELERY_BROKER_REDIS_DATABASE=1 CELERY_BROKER_REDIS_DATABASE=1
@@ -28,8 +26,6 @@ OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test' OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test' OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test' OAUTH2_GOOGLE_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# [ Plugin ] email # [ Plugin ] email
EMAIL_USERNAME='' EMAIL_USERNAME=''
EMAIL_PASSWORD='' EMAIL_PASSWORD=''
-65
View File
@@ -16,68 +16,3 @@
```shell ```shell
docker run -d -p 8000:8000 --name fba_server fba_backend_independent 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 -4
View File
@@ -1,7 +1,17 @@
from backend.common.i18n import i18n import sqlalchemy as sa
__version__ = '1.8.2' from backend.utils.dynamic_import import get_all_models
# import all models for auto create db tables
for cls in get_all_models():
if isinstance(cls, sa.Table):
table_name = cls.name
if table_name not in globals():
globals()[table_name] = cls
else:
class_name = cls.__name__
if class_name not in globals():
globals()[class_name] = cls
# 初始化 i18n __version__ = '1.13.4'
i18n.load_locales()
-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 # Logging configuration
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
+13 -17
View File
@@ -8,36 +8,32 @@ from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from backend.app import get_app_models
from backend.common.model import MappedBase from backend.common.model import MappedBase
from backend.core import path_conf from backend.core import path_conf
from backend.core.path_conf import BASE_PATH
from backend.database.db import SQLALCHEMY_DATABASE_URL 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
if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR): if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR):
os.makedirs(path_conf.ALEMBIC_VERSION_DIR) os.makedirs(path_conf.ALEMBIC_VERSION_DIR)
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
alembic_config = context.config config = context.config
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
if alembic_config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(alembic_config.config_file_name) fileConfig(BASE_PATH / config.config_file_name)
# model's MetaData object # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
target_metadata = MappedBase.metadata target_metadata = MappedBase.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
alembic_config.set_main_option( # can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
config.set_main_option(
'sqlalchemy.url', 'sqlalchemy.url',
SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'), SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'),
) )
@@ -55,7 +51,7 @@ def run_migrations_offline() -> None:
script output. script output.
""" """
url = alembic_config.get_main_option('sqlalchemy.url') url = config.get_main_option('sqlalchemy.url')
context.configure( context.configure(
url=url, url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
@@ -71,9 +67,9 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None: def do_run_migrations(connection: Connection) -> None:
# 当迁移无变化时,不生成迁移记录
def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001 def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001
if alembic_config.cmd_opts.autogenerate: """当迁移无变化时,不生成迁移记录"""
if config.cmd_opts.autogenerate:
script = directives[0] script = directives[0]
if script.upgrade_ops.is_empty(): if script.upgrade_ops.is_empty():
directives[:] = [] directives[:] = []
@@ -99,7 +95,7 @@ async def run_async_migrations() -> None:
""" """
connectable = async_engine_from_config( connectable = async_engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}), config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.', prefix='sqlalchemy.',
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
-29
View File
@@ -1,29 +0,0 @@
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 = BASE_PATH / 'app'
list_dirs = os.listdir(app_path)
apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
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
+6 -5
View File
@@ -2,7 +2,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from fastapi.security import HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from fastapi_limiter.depends import RateLimiter from pyrate_limiter import Duration, Rate
from starlette.background import BackgroundTasks from starlette.background import BackgroundTasks
from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwaggerToken
@@ -11,6 +11,7 @@ from backend.app.admin.service.auth_service import auth_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.utils.limiter import RateLimiter
router = APIRouter() router = APIRouter()
@@ -20,14 +21,14 @@ async def login_swagger(
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()] db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
) -> GetSwaggerToken: ) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(db=db, obj=obj) token, user = await auth_service.swagger_login(db=db, obj=obj)
return GetSwaggerToken(access_token=token, user=user) return GetSwaggerToken(access_token=token, user=user) # type: ignore
@router.post( @router.post(
'/login', '/login',
summary='用户登录', summary='用户登录',
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman', description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
dependencies=[Depends(RateLimiter(times=5, minutes=1))], dependencies=[Depends(RateLimiter(Rate(5, Duration.MINUTE)))],
) )
async def login( async def login(
db: CurrentSessionTransaction, db: CurrentSessionTransaction,
@@ -46,8 +47,8 @@ async def get_codes(db: CurrentSession, request: Request) -> ResponseSchemaModel
@router.post('/refresh', summary='刷新 token') @router.post('/refresh', summary='刷新 token')
async def refresh_token(db: CurrentSession, request: Request) -> ResponseSchemaModel[GetNewToken]: async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(db=db, request=request) data = await auth_service.refresh_token(db=db, request=request, response=response)
return response_base.success(data=data) return response_base.success(data=data)
+18 -13
View File
@@ -1,14 +1,17 @@
from uuid import uuid4 import uuid
from fast_captcha import img_captcha from fast_captcha import img_captcha
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi_limiter.depends import RateLimiter from pyrate_limiter import Duration, Rate
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.admin.schema.captcha import GetCaptchaDetail from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import CurrentSession
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
from backend.utils.limiter import RateLimiter
router = APIRouter() router = APIRouter()
@@ -16,19 +19,21 @@ router = APIRouter()
@router.get( @router.get(
'/captcha', '/captcha',
summary='获取登录验证码', summary='获取登录验证码',
dependencies=[Depends(RateLimiter(times=5, seconds=10))], dependencies=[Depends(RateLimiter(Rate(5, Duration.SECOND * 30)))],
) )
async def get_captcha() -> ResponseSchemaModel[GetCaptchaDetail]: async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
""" await load_login_config(db)
此接口可能存在性能损耗尽管是异步接口但是验证码生成是IO密集型任务使用线程池尽量减少性能损耗 img, code = await run_in_threadpool(img_captcha, img_byte='base64')
""" captcha_uuid = str(uuid.uuid4())
img_type: str = 'base64'
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
uuid = str(uuid4())
await redis_client.set( await redis_client.set(
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}', f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
code, 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) return response_base.success(data=data)
+4 -4
View File
@@ -7,18 +7,18 @@ from fastapi import APIRouter, Path, Query
from backend.app.admin.schema.token import GetTokenDetail from backend.app.admin.schema.token import GetTokenDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser, jwt_decode, revoke_token from backend.common.security.jwt import DependsSuperUser, jwt_decode, revoke_token
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
router = APIRouter() router = APIRouter()
@router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth]) @router.get('', summary='获取在线用户', dependencies=[DependsSuperUser])
async def get_sessions( async def get_sessions(
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
) -> ResponseSchemaModel[list[GetTokenDetail]]: ) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*') token_keys = await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:*')
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX) online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data: list[GetTokenDetail] = [] data: list[GetTokenDetail] = []
@@ -40,7 +40,7 @@ async def get_sessions(
for key in token_keys: for key in token_keys:
token = await redis_client.get(key) token = await redis_client.get(key)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id user_id = token_payload.user_id
session_uuid = token_payload.session_uuid session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail( token_detail = GetTokenDetail(
id=user_id, id=user_id,
+34 -8
View File
@@ -1,16 +1,42 @@
from fastapi import APIRouter from fastapi import APIRouter
from backend.common.response.response_schema import ResponseModel, response_base from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.utils.redis_info import redis_info from backend.database.redis import redis_client
from backend.utils.format import fmt_seconds
router = APIRouter() router = APIRouter()
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth]) @router.get('', summary='Redis 监控', dependencies=[DependsJwtAuth])
async def get_redis_info() -> ResponseModel: async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
data = { info = await redis_client.info()
'info': await redis_info.get_info(), db_size = await redis_client.dbsize()
'stats': await redis_info.get_stats(),
} server_info = RedisServerInfo(
redis_version=str(info.get('redis_version', '')),
redis_mode=str(info.get('redis_mode', '')),
role=str(info.get('role', '')),
tcp_port=str(info.get('tcp_port', '')),
uptime=str(fmt_seconds(int(info.get('uptime_in_seconds', 0)))),
connected_clients=str(info.get('connected_clients', '')),
blocked_clients=str(info.get('blocked_clients', '')),
used_memory_human=str(info.get('used_memory_human', '')),
used_memory_rss_human=str(info.get('used_memory_rss_human', '')),
maxmemory_human=str(info.get('maxmemory_human', '0B')),
mem_fragmentation_ratio=str(info.get('mem_fragmentation_ratio', '0')),
instantaneous_ops_per_sec=str(info.get('instantaneous_ops_per_sec', '')),
total_commands_processed=str(info.get('total_commands_processed', '')),
rejected_connections=str(info.get('rejected_connections', '')),
keys_num=str(db_size),
)
command_stats = await redis_client.info('commandstats')
stats_list = []
for key, value in command_stats.items():
if isinstance(value, dict):
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
data = RedisMonitorInfo(info=server_info, stats=stats_list)
return response_base.success(data=data) return response_base.success(data=data)
+126 -13
View File
@@ -1,21 +1,134 @@
import os
import platform
import socket
import sys
from datetime import datetime
from datetime import timezone as tz
import psutil
from fastapi import APIRouter from fastapi import APIRouter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.common.response.response_schema import ResponseModel, response_base from backend.app.admin.schema.monitor import (
from backend.common.security.jwt import DependsJwtAuth CpuInfo,
from backend.utils.server_info import server_info DiskInfo,
MemInfo,
ServerMonitorInfo,
ServiceInfo,
SysInfo,
)
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsSuperUser
from backend.utils.format import fmt_bytes, fmt_seconds
from backend.utils.timezone import timezone
router = APIRouter() router = APIRouter()
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth]) @router.get('', summary='Server 监控', dependencies=[DependsSuperUser])
async def get_server_info() -> ResponseModel: async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
data = { def get_all_info() -> ServerMonitorInfo: # noqa: C901
# 扔到线程池,避免阻塞 # CPU 信息
'cpu': await run_in_threadpool(server_info.get_cpu_info), cpu_data = {
'mem': await run_in_threadpool(server_info.get_mem_info), 'physical_num': psutil.cpu_count(logical=False) or 0,
'sys': await run_in_threadpool(server_info.get_sys_info), 'logical_num': psutil.cpu_count(logical=True) or 0,
'disk': await run_in_threadpool(server_info.get_disk_info), 'max_freq': 0.0,
'service': await run_in_threadpool(server_info.get_service_info), 'min_freq': 0.0,
} 'current_freq': 0.0,
'usage': round(psutil.cpu_percent(interval=0.1), 2),
}
try:
if hasattr(psutil, 'cpu_freq'):
cpu_freq = psutil.cpu_freq()
if cpu_freq:
cpu_data.update({
'max_freq': round(cpu_freq.max, 2),
'min_freq': round(cpu_freq.min, 2),
'current_freq': round(cpu_freq.current, 2),
})
except Exception:
pass
cpu = CpuInfo(**cpu_data)
# 内存信息
mem = psutil.virtual_memory()
gb_factor = 1024**3
mem_info = MemInfo(
total=round(mem.total / gb_factor, 2),
used=round(mem.used / gb_factor, 2),
free=round(mem.available / gb_factor, 2),
usage=round(mem.percent, 2),
)
# 系统信息
hostname = socket.gethostname()
ip = '127.0.0.1'
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(0.5)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except (TimeoutError, socket.gaierror, OSError):
pass
sys_info = SysInfo(name=hostname, os=platform.system(), ip=ip, arch=platform.machine())
# 磁盘信息
disk_list = []
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
seen_devices = set()
for partition in psutil.disk_partitions(all=False):
# 跳过虚拟文件系统
if partition.fstype.lower() in exclude_fstypes:
continue
# 跳过重复设备(同一设备的不同挂载点)
if partition.device in seen_devices:
continue
try:
usage = psutil.disk_usage(partition.mountpoint)
if usage:
seen_devices.add(partition.device)
disk_list.append(
DiskInfo(
dir=partition.mountpoint,
device=partition.device,
type=partition.fstype,
total=fmt_bytes(usage.total),
used=fmt_bytes(usage.used),
free=fmt_bytes(usage.free),
usage=f'{usage.percent:.2f}%',
)
)
except (PermissionError, OSError):
continue
# 服务信息
process = psutil.Process(os.getpid())
proc_mem = process.memory_info()
try:
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
start_time = timezone.from_datetime(create_time)
except (psutil.NoSuchProcess, OSError):
start_time = timezone.now()
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
service = ServiceInfo(
name='Python3',
version=platform.python_version(),
home=sys.executable,
startup=timezone.to_str(start_time),
elapsed=elapsed,
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
mem_vms=fmt_bytes(proc_mem.vms),
mem_rss=fmt_bytes(proc_mem.rss),
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
)
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
data = await run_in_threadpool(get_all_info)
return response_base.success(data=data) return response_base.success(data=data)
+1 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router from backend.app.admin.api.v1.sys.data_scope import router as data_scope_router
from backend.app.admin.api.v1.sys.dept import router as dept_router from backend.app.admin.api.v1.sys.dept import router as dept_router
from backend.app.admin.api.v1.sys.files import router as file_router from backend.app.admin.api.v1.sys.file import router as file_router
from backend.app.admin.api.v1.sys.menu import router as menu_router from backend.app.admin.api.v1.sys.menu import router as menu_router
from backend.app.admin.api.v1.sys.plugin import router as plugin_router from backend.app.admin.api.v1.sys.plugin import router as plugin_router
from backend.app.admin.api.v1.sys.role import router as role_router from backend.app.admin.api.v1.sys.role import router as role_router
@@ -7,6 +7,7 @@ from backend.app.admin.schema.data_rule import (
DeleteDataRuleParam, DeleteDataRuleParam,
GetDataRuleColumnDetail, GetDataRuleColumnDetail,
GetDataRuleDetail, GetDataRuleDetail,
GetDataRuleTemplateVariableDetail,
UpdateDataRuleParam, UpdateDataRuleParam,
) )
from backend.app.admin.service.data_rule_service import data_rule_service from backend.app.admin.service.data_rule_service import data_rule_service
@@ -34,6 +35,12 @@ async def get_data_rule_model_columns(
return response_base.success(data=models) return response_base.success(data=models)
@router.get('/value-template-variables', summary='获取数据规则值可用模板变量', dependencies=[DependsJwtAuth])
async def get_data_rule_value_template_variables() -> ResponseSchemaModel[list[GetDataRuleTemplateVariableDetail]]:
variables = await data_rule_service.get_value_template_variables()
return response_base.success(data=variables)
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rules(db: CurrentSession) -> ResponseSchemaModel[list[GetDataRuleDetail]]: async def get_all_data_rules(db: CurrentSession) -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data = await data_rule_service.get_all(db=db) data = await data_rule_service.get_all(db=db)
+8 -4
View File
@@ -1,12 +1,14 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import ColumnElement
from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service from backend.app.admin.service.dept_service import dept_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
from backend.common.security.permission import RequestPermission from backend.common.security.permission import DataPermissionFilter, RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.database.db import CurrentSession, CurrentSessionTransaction
@@ -24,13 +26,15 @@ async def get_dept(
@router.get('', summary='获取部门树', dependencies=[DependsJwtAuth]) @router.get('', summary='获取部门树', dependencies=[DependsJwtAuth])
async def get_dept_tree( async def get_dept_tree(
db: CurrentSession, db: CurrentSession,
request: Request, data_filter: Annotated[ColumnElement[bool], Depends(DataPermissionFilter(Dept))],
name: Annotated[str | None, Query(description='部门名称')] = None, name: Annotated[str | None, Query(description='部门名称')] = None,
leader: Annotated[str | None, Query(description='部门负责人')] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None, phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[GetDeptTree]]: ) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(db=db, request=request, name=name, leader=leader, phone=phone, status=status) dept = await dept_service.get_tree(
db=db, data_filter=data_filter, name=name, leader=leader, phone=phone, status=status
)
return response_base.success(data=dept) return response_base.success(data=dept)
@@ -13,7 +13,7 @@ router = APIRouter()
@router.post( @router.post(
'/upload', '/upload',
summary='文件上传', summary='本地文件上传',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:file:upload')), Depends(RequestPermission('sys:file:upload')),
DependsRBAC, DependsRBAC,
+10 -25
View File
@@ -1,6 +1,6 @@
from typing import Annotated, Any 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 fastapi.params import Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
@@ -8,20 +8,18 @@ from backend.app.admin.service.plugin_service import plugin_service
from backend.common.enums import PluginType from backend.common.enums import PluginType
from backend.common.response.response_code import CustomResponse from backend.common.response.response_code import CustomResponse
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsSuperUser
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
router = APIRouter() router = APIRouter()
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth]) @router.get('', summary='获取所有插件', dependencies=[DependsSuperUser])
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]: async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
plugins = await plugin_service.get_all() plugins = await plugin_service.get_all()
return response_base.success(data=plugins) return response_base.success(data=plugins)
@router.get('/changed', summary='是否存在插件变更', dependencies=[DependsJwtAuth]) @router.get('/changed', summary='是否存在插件变更', dependencies=[DependsSuperUser])
async def plugin_changed() -> ResponseSchemaModel[bool]: async def plugin_changed() -> ResponseSchemaModel[bool]:
plugins = await plugin_service.changed() plugins = await plugin_service.changed()
return response_base.success(data=bool(plugins)) return response_base.success(data=bool(plugins))
@@ -30,11 +28,8 @@ async def plugin_changed() -> ResponseSchemaModel[bool]:
@router.post( @router.post(
'', '',
summary='安装插件', summary='安装插件',
description='使用插件 zip 压缩包或 git 仓库地址进行安装', description='使用插件 zip 压缩包或 git 仓库地址进行安装(仅开发环境)',
dependencies=[ dependencies=[DependsSuperUser],
Depends(RequestPermission('sys:plugin:install')),
DependsRBAC,
],
) )
async def install_plugin( async def install_plugin(
type: Annotated[PluginType, Query(description='插件类型')], type: Annotated[PluginType, Query(description='插件类型')],
@@ -53,11 +48,8 @@ async def install_plugin(
@router.delete( @router.delete(
'/{plugin}', '/{plugin}',
summary='卸载插件', summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录', description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录(仅开发环境)',
dependencies=[ dependencies=[DependsSuperUser],
Depends(RequestPermission('sys:plugin:uninstall')),
DependsRBAC,
],
) )
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin) await plugin_service.uninstall(plugin=plugin)
@@ -66,20 +58,13 @@ async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称
) )
@router.put( @router.put('/{plugin}/status', summary='更新插件状态', dependencies=[DependsSuperUser])
'/{plugin}/status',
summary='更新插件状态',
dependencies=[
Depends(RequestPermission('sys:plugin:edit')),
DependsRBAC,
],
)
async def update_plugin_status(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: async def update_plugin_status(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.update_status(plugin=plugin) await plugin_service.update_status(plugin=plugin)
return response_base.success() 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: async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
bio = await plugin_service.build(plugin=plugin) bio = await plugin_service.build(plugin=plugin)
return StreamingResponse( return StreamingResponse(
+4 -4
View File
@@ -102,7 +102,7 @@ async def update_user_permission(
async def update_user_password( async def update_user_password(
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_password(db=db, request=request, obj=obj) count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -126,7 +126,7 @@ async def update_user_nickname(
request: Request, request: Request,
nickname: Annotated[str, Body(embed=True, description='用户昵称')], nickname: Annotated[str, Body(embed=True, description='用户昵称')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_nickname(db=db, request=request, nickname=nickname) count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -138,7 +138,7 @@ async def update_user_avatar(
request: Request, request: Request,
avatar: Annotated[str, Body(embed=True, description='用户头像地址')], avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_avatar(db=db, request=request, avatar=avatar) count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -151,7 +151,7 @@ async def update_user_email(
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')], captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
email: Annotated[str, Body(embed=True, description='用户邮箱')], email: Annotated[str, Body(embed=True, description='用户邮箱')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_email(db=db, request=request, captcha=captcha, email=email) count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+1 -1
View File
@@ -33,7 +33,7 @@ class CRUDDataRule(CRUDPlus[DataRule]):
if name is not None: if name is not None:
filters['name__like'] = f'%{name}%' filters['name__like'] = f'%{name}%'
return await self.select_order('id', load_strategies={'scopes': 'noload'}, **filters) return await self.select_order('id', **filters)
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None: async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
""" """
+37 -13
View File
@@ -1,11 +1,18 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, select from sqlalchemy import Select, delete, insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataRule, DataScope from backend.app.admin.model import DataRule, DataScope, data_scope_rule
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
CreateDataScopeRuleParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.utils.serializers import select_join_serialize
class CRUDDataScope(CRUDPlus[DataScope]): class CRUDDataScope(CRUDPlus[DataScope]):
@@ -31,7 +38,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
""" """
return await self.select_model_by_column(db, name=name) return await self.select_model_by_column(db, name=name)
async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope: async def get_join(self, db: AsyncSession, pk: int) -> Any:
""" """
获取数据范围关联数据 获取数据范围关联数据
@@ -39,7 +46,16 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param pk: 范围 ID :param pk: 范围 ID
:return: :return:
""" """
return await self.select_model(db, pk, load_strategies=['rules']) result = await self.select_models(
db,
id=pk,
join_conditions=[
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == self.model.id),
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
],
)
return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]: async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
""" """
@@ -65,7 +81,7 @@ class CRUDDataScope(CRUDPlus[DataScope]):
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters) return await self.select_order('id', **filters)
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None: async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
""" """
@@ -88,7 +104,8 @@ class CRUDDataScope(CRUDPlus[DataScope]):
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int: @staticmethod
async def update_rules(db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
""" """
更新数据范围规则 更新数据范围规则
@@ -97,11 +114,18 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param rule_ids: 数据规则 ID 列表 :param rule_ids: 数据规则 ID 列表
:return: :return:
""" """
current_data_scope = await self.get_with_relation(db, pk) data_scope_rule_stmt = delete(data_scope_rule).where(data_scope_rule.c.data_scope_id == pk)
stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) await db.execute(data_scope_rule_stmt)
rules = await db.execute(stmt)
current_data_scope.rules = rules.scalars().all() if rule_ids.rules:
return len(current_data_scope.rules) data_scope_rule_data = [
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump()
for rule_id in rule_ids.rules
]
data_scope_rule_stmt = insert(data_scope_rule)
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
return len(rule_ids.rules)
async def delete(self, db: AsyncSession, pks: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
+19 -14
View File
@@ -1,12 +1,13 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from fastapi import Request from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import Dept from backend.app.admin.model import Dept, User
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.security.permission import filter_data_permission from backend.utils.serializers import select_join_serialize
class CRUDDept(CRUDPlus[Dept]): class CRUDDept(CRUDPlus[Dept]):
@@ -20,7 +21,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
return await self.select_model_by_column(db, id=dept_id, del_flag=0) return await self.select_model_by_column(db, id=dept_id, del_flag=False)
async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None: async def get_by_name(self, db: AsyncSession, name: str) -> Dept | None:
""" """
@@ -30,12 +31,12 @@ class CRUDDept(CRUDPlus[Dept]):
:param name: 部门名称 :param name: 部门名称
:return: :return:
""" """
return await self.select_model_by_column(db, name=name, del_flag=0) return await self.select_model_by_column(db, name=name, del_flag=False)
async def get_all( async def get_all(
self, self,
request: Request,
db: AsyncSession, db: AsyncSession,
data_filter: ColumnElement[bool],
name: str | None, name: str | None,
leader: str | None, leader: str | None,
phone: str | None, phone: str | None,
@@ -44,15 +45,15 @@ class CRUDDept(CRUDPlus[Dept]):
""" """
获取所有部门 获取所有部门
:param request: FastAPI 请求对象
:param db: 数据库会话 :param db: 数据库会话
:param data_filter: 请求用户
:param name: 部门名称 :param name: 部门名称
:param leader: 负责人 :param leader: 负责人
:param phone: 联系电话 :param phone: 联系电话
:param status: 部门状态 :param status: 部门状态
:return: :return:
""" """
filters = {'del_flag': 0} filters = {'del_flag': False}
if name is not None: if name is not None:
filters['name__like'] = f'%{name}%' filters['name__like'] = f'%{name}%'
@@ -63,8 +64,7 @@ class CRUDDept(CRUDPlus[Dept]):
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
data_filtered = await filter_data_permission(db, request) return await self.select_models_order(db, 'sort', 'asc', data_filter, **filters)
return await self.select_models_order(db, 'sort', 'desc', data_filtered, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
@@ -97,7 +97,7 @@ class CRUDDept(CRUDPlus[Dept]):
""" """
return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag') return await self.delete_model_by_column(db, id=dept_id, logical_deletion=True, deleted_flag_column='del_flag')
async def get_with_relation(self, db: AsyncSession, dept_id: int) -> Dept | None: async def get_join(self, db: AsyncSession, dept_id: int) -> Any | None:
""" """
获取部门及关联数据 获取部门及关联数据
@@ -105,7 +105,12 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
return await self.select_model(db, dept_id, load_strategies=['users']) result = await self.select_model(
db,
dept_id,
join_conditions=[JoinConfig(model=User, join_on=User.dept_id == self.model.id, fill_result=True)],
)
return select_join_serialize(result, relationships=['Dept-o2m-User'])
async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]: async def get_children(self, db: AsyncSession, dept_id: int) -> Sequence[Dept | None]:
""" """
@@ -115,7 +120,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
return await self.select_models(db, parent_id=dept_id, del_flag=0) return await self.select_models(db, parent_id=dept_id, del_flag=False)
dept_dao: CRUDDept = CRUDDept(Dept) dept_dao: CRUDDept = CRUDDept(Dept)
+8 -5
View File
@@ -1,9 +1,10 @@
from collections.abc import Sequence from collections.abc import Sequence
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Menu from backend.app.admin.model import Menu, role_menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
@@ -46,7 +47,7 @@ class CRUDMenu(CRUDPlus[Menu]):
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
return await self.select_models_order(db, 'sort', **filters) return await self.select_models_order(db, 'sort', 'asc', **filters)
async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]: async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
""" """
@@ -92,9 +93,12 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID :param menu_id: 菜单 ID
:return: :return:
""" """
role_menu_stmt = delete(role_menu).where(role_menu.c.menu_id == menu_id)
await db.execute(role_menu_stmt)
return await self.delete_model(db, menu_id) return await self.delete_model(db, menu_id)
async def get_children(self, db: AsyncSession, menu_id: int) -> list[Menu | None]: async def get_children(self, db: AsyncSession, menu_id: int) -> Sequence[Menu | None]:
""" """
获取子菜单列表 获取子菜单列表
@@ -102,8 +106,7 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID :param menu_id: 菜单 ID
:return: :return:
""" """
menu = await self.select_model(db, menu_id, load_strategies=['children']) return await self.select_models(db, parent_id=menu_id)
return menu.children
menu_dao: CRUDMenu = CRUDMenu(Menu) menu_dao: CRUDMenu = CRUDMenu(Menu)
+61 -26
View File
@@ -1,16 +1,20 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, select from sqlalchemy import Select, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataScope, Menu, Role from backend.app.admin.model import DataScope, Menu, Role, role_data_scope, role_menu
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleMenuParam,
CreateRoleParam, CreateRoleParam,
CreateRoleScopeParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleScopeParam, UpdateRoleScopeParam,
) )
from backend.utils.serializers import select_join_serialize
class CRUDRole(CRUDPlus[Role]): class CRUDRole(CRUDPlus[Role]):
@@ -26,7 +30,20 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.select_model(db, role_id) return await self.select_model(db, role_id)
async def get_with_relation(self, db: AsyncSession, role_id: int) -> Role | None: @staticmethod
async def get_menus(db: AsyncSession, role_id: int) -> Sequence[Menu] | None:
"""
获取角色菜单
:param db: 数据库会话
:param role_id: 角色 ID
:return:
"""
menu_stmt = select(Menu).join(role_menu, Menu.id == role_menu.c.menu_id).where(role_menu.c.role_id == role_id)
result = await db.execute(menu_stmt)
return result.scalars().all()
async def get_join(self, db: AsyncSession, role_id: int) -> Any:
""" """
获取角色及关联数据 获取角色及关联数据
@@ -34,7 +51,18 @@ class CRUDRole(CRUDPlus[Role]):
:param role_id: 角色 ID :param role_id: 角色 ID
:return: :return:
""" """
return await self.select_model(db, role_id, load_strategies=['menus', 'scopes']) result = await self.select_models(
db,
id=role_id,
join_conditions=[
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == self.model.id),
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == self.model.id),
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
],
)
return select_join_serialize(result, relationships=['Role-m2m-Menu', 'Role-m2m-DataScope:scopes'])
async def get_all(self, db: AsyncSession) -> Sequence[Role]: async def get_all(self, db: AsyncSession) -> Sequence[Role]:
""" """
@@ -61,15 +89,7 @@ class CRUDRole(CRUDPlus[Role]):
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
return await self.select_order( return await self.select_order('id', **filters)
'id',
load_strategies={
'users': 'noload',
'menus': 'noload',
'scopes': 'noload',
},
**filters,
)
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None: async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
""" """
@@ -102,7 +122,8 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.update_model(db, role_id, obj) return await self.update_model(db, role_id, obj)
async def update_menus(self, db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int: @staticmethod
async def update_menus(db: AsyncSession, role_id: int, menu_ids: UpdateRoleMenuParam) -> int:
""" """
更新角色菜单 更新角色菜单
@@ -111,13 +132,20 @@ class CRUDRole(CRUDPlus[Role]):
:param menu_ids: 菜单 ID 列表 :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
current_role = await self.get_with_relation(db, role_id) role_menu_stmt = delete(role_menu).where(role_menu.c.role_id == role_id)
stmt = select(Menu).where(Menu.id.in_(menu_ids.menus)) await db.execute(role_menu_stmt)
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: if menu_ids.menus:
role_menu_data = [
CreateRoleMenuParam(role_id=role_id, menu_id=menu_id).model_dump() for menu_id in menu_ids.menus
]
role_menu_stmt = insert(role_menu)
await db.execute(role_menu_stmt, role_menu_data)
return len(menu_ids.menus)
@staticmethod
async def update_scopes(db: AsyncSession, role_id: int, scope_ids: UpdateRoleScopeParam) -> int:
""" """
更新角色数据范围 更新角色数据范围
@@ -126,11 +154,18 @@ class CRUDRole(CRUDPlus[Role]):
:param scope_ids: 权限范围 ID 列表 :param scope_ids: 权限范围 ID 列表
:return: :return:
""" """
current_role = await self.get_with_relation(db, role_id) role_scope_stmt = delete(role_data_scope).where(role_data_scope.c.role_id == role_id)
stmt = select(DataScope).where(DataScope.id.in_(scope_ids.scopes)) await db.execute(role_scope_stmt)
scopes = await db.execute(stmt)
current_role.scopes = scopes.scalars().all() if scope_ids.scopes:
return len(current_role.scopes) role_scope_data = [
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump()
for scope_id in scope_ids.scopes
]
role_scope_stmt = insert(role_data_scope)
await db.execute(role_scope_stmt, role_scope_data)
return len(scope_ids.scopes)
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int: async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
""" """
+162 -83
View File
@@ -1,18 +1,34 @@
from typing import Any
import bcrypt import bcrypt
from sqlalchemy import select from sqlalchemy import Select, delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from sqlalchemy.sql import Select
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Dept, Role, User from backend.app.admin.model import (
DataRule,
DataScope,
Dept,
Menu,
Role,
User,
data_scope_rule,
role_data_scope,
role_menu,
user_role,
)
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddOAuth2UserParam, AddOAuth2UserParam,
AddUserParam, AddUserParam,
AddUserRoleParam,
UpdateUserParam, UpdateUserParam,
) )
from backend.common.security.jwt import get_hash_password from backend.app.admin.utils.password_security import get_hash_password
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.plugin.core import check_plugin_installed
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -49,15 +65,47 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.select_model_by_column(db, nickname=nickname) return await self.select_model_by_column(db, nickname=nickname)
async def update_login_time(self, db: AsyncSession, username: str) -> int: async def check_email(self, db: AsyncSession, email: str) -> User | None:
""" """
更新用户最后登录时间 检查邮箱是否已被绑定
:param db: 数据库会话 :param db: 数据库会话
:param username: 用户名 :param email: 电子邮箱
:return: :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)
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表查询表达式
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {}
if dept:
filters['dept_id'] = dept
if username:
filters['username__like'] = f'%{username}%'
if phone:
filters['phone__like'] = f'%{phone}%'
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
'desc',
join_conditions=[
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
],
**filters,
)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
@@ -69,15 +117,21 @@ class CRUDUser(CRUDPlus[User]):
""" """
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
obj.password = get_hash_password(obj.password, salt) obj.password = get_hash_password(obj.password, salt)
dict_obj = obj.model_dump(exclude={'roles'}) dict_obj = obj.model_dump(exclude={'roles'})
dict_obj.update({'salt': salt}) dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
stmt = select(Role).where(Role.id.in_(obj.roles))
roles = await db.execute(stmt)
new_user.roles = roles.scalars().all()
db.add(new_user) db.add(new_user)
await db.flush()
if obj.roles:
role_stmt = select(Role).where(Role.id.in_(obj.roles))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_data = [AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None: async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None:
""" """
@@ -90,31 +144,66 @@ class CRUDUser(CRUDPlus[User]):
dict_obj = obj.model_dump() dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None}) dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj) new_user = self.model(**dict_obj)
stmt = select(Role)
role = await db.execute(stmt)
new_user.roles = [role.scalars().first()] # 默认绑定第一个角色
db.add(new_user) db.add(new_user)
await db.flush()
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int: role_stmt = select(Role).where(Role.status == StatusType.enable)
result = await db.execute(role_stmt)
role = result.scalars().first() # 默认绑定第一个角色
if role is None:
raise errors.NotFoundError(msg='未找到可用角色,请联系系统管理员')
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
await db.execute(user_role_stmt)
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
""" """
更新用户信息 更新用户信息
:param db: 数据库会话 :param db: 数据库会话
:param input_user: 用户 ID :param user_id: 用户 ID
:param obj: 更新用户参数 :param obj: 更新用户参数
:return: :return:
""" """
role_ids = obj.roles role_ids = obj.roles
del obj.roles del obj.roles
count = await self.update_model(db, input_user.id, obj)
stmt = select(Role).where(Role.id.in_(role_ids)) count = await self.update_model(db, user_id, obj)
roles = await db.execute(stmt)
input_user.roles = roles.scalars().all() user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
if role_ids:
role_stmt = select(Role).where(Role.id.in_(role_ids))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
return count 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)
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(db, user_id, {'last_password_changed_time': timezone.now()})
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int: async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
""" """
更新用户昵称 更新用户昵称
@@ -148,26 +237,6 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'email': email}) 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)
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int: async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
""" """
重置用户密码 重置用户密码
@@ -179,38 +248,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
new_pwd = get_hash_password(password, salt) new_pwd = get_hash_password(password, salt)
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}) return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表查询表达式
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {}
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:
""" """
@@ -256,13 +294,34 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'is_multi_login': multi_login}) return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def get_with_relation( async def delete(self, db: AsyncSession, user_id: int) -> int:
"""
删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
if check_plugin_installed('oauth2'):
try:
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
await user_social_dao.delete_by_user_id(db, user_id)
except ImportError:
raise errors.ServerError(msg='OAuth2 插件用法导入失败,请联系系统管理员')
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
return await self.delete_model(db, user_id)
async def get_join(
self, self,
db: AsyncSession, db: AsyncSession,
*, *,
user_id: int | None = None, user_id: int | None = None,
username: str | None = None, username: str | None = None,
) -> User | None: ) -> Any | None:
""" """
获取用户关联信息 获取用户关联信息
@@ -278,12 +337,32 @@ class CRUDUser(CRUDPlus[User]):
if username: if username:
filters['username'] = username filters['username'] = username
return await self.select_model_by_column( result = await self.select_models(
db, db,
load_options=[selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes))], join_conditions=[
load_strategies=['dept'], JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
JoinConfig(model=role_menu, join_on=role_menu.c.role_id == Role.id),
JoinConfig(model=Menu, join_on=Menu.id == role_menu.c.menu_id, fill_result=True),
JoinConfig(model=role_data_scope, join_on=role_data_scope.c.role_id == Role.id),
JoinConfig(model=DataScope, join_on=DataScope.id == role_data_scope.c.data_scope_id, fill_result=True),
JoinConfig(model=data_scope_rule, join_on=data_scope_rule.c.data_scope_id == DataScope.id),
JoinConfig(model=DataRule, join_on=DataRule.id == data_scope_rule.c.data_rule_id, fill_result=True),
],
**filters, **filters,
) )
return select_join_serialize(
result,
relationships=[
'User-m2o-Dept',
'User-m2m-Role',
'Role-m2m-Menu',
'Role-m2m-DataScope:scopes',
'DataScope-m2m-DataRule:rules',
],
)
user_dao: CRUDUser = CRUDUser(User) user_dao: CRUDUser = CRUDUser(User)
@@ -0,0 +1,34 @@
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model.user_password_history import UserPasswordHistory
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
"""用户密码历史记录数据库操作类"""
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
"""
创建密码历史记录
:param db: 数据库会话
:param obj: 创建密码历史记录参数
:return:
"""
await self.create_model(db, obj)
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
"""
获取用户的密码历史记录
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)
+5
View File
@@ -2,7 +2,12 @@ from backend.app.admin.model.data_rule import DataRule as DataRule
from backend.app.admin.model.data_scope import DataScope as DataScope from backend.app.admin.model.data_scope import DataScope as DataScope
from backend.app.admin.model.dept import Dept as Dept from backend.app.admin.model.dept import Dept as Dept
from backend.app.admin.model.login_log import LoginLog as LoginLog from backend.app.admin.model.login_log import LoginLog as LoginLog
from backend.app.admin.model.m2m import data_scope_rule as data_scope_rule
from backend.app.admin.model.m2m import role_data_scope as role_data_scope
from backend.app.admin.model.m2m import role_menu as role_menu
from backend.app.admin.model.m2m import user_role as user_role
from backend.app.admin.model.menu import Menu as Menu from backend.app.admin.model.menu import Menu as Menu
from backend.app.admin.model.opera_log import OperaLog as OperaLog from backend.app.admin.model.opera_log import OperaLog as OperaLog
from backend.app.admin.model.role import Role as Role from backend.app.admin.model.role import Role as Role
from backend.app.admin.model.user import User as User from backend.app.admin.model.user import User as User
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
+6 -16
View File
@@ -1,16 +1,9 @@
from __future__ import annotations import sqlalchemy as sa
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
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 from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataScope
class DataRule(Base): class DataRule(Base):
"""数据规则表""" """数据规则表"""
@@ -18,14 +11,11 @@ class DataRule(Base):
__tablename__ = 'sys_data_rule' __tablename__ = 'sys_data_rule'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(500), unique=True, comment='名称') name: Mapped[str] = mapped_column(sa.String(512), unique=True, comment='名称')
model: Mapped[str] = mapped_column(String(50), comment='SQLA 模型名,对应 DATA_PERMISSION_MODELS 键名') model: Mapped[str] = mapped_column(sa.String(64), comment='模型名称')
column: Mapped[str] = mapped_column(String(20), comment='模型字段名') column: Mapped[str] = mapped_column(sa.String(32), comment='模型字段名')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or') operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column( expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in', comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in',
) )
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(sa.String(256), comment='规则值')
# 数据范围规则多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='rules')
+3 -16
View File
@@ -1,16 +1,9 @@
from __future__ import annotations import sqlalchemy as sa
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
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 from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import DataRule, Role
class DataScope(Base): class DataScope(Base):
"""数据范围表""" """数据范围表"""
@@ -18,11 +11,5 @@ class DataScope(Base):
__tablename__ = 'sys_data_scope' __tablename__ = 'sys_data_scope'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='名称') name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='名称')
status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)')
# 数据范围规则多对多
rules: Mapped[list[DataRule]] = relationship(init=False, secondary=sys_data_scope_rule, back_populates='scopes')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+9 -31
View File
@@ -1,16 +1,9 @@
from __future__ import annotations import sqlalchemy as sa
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
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 from backend.common.model import Base, id_key
if TYPE_CHECKING:
from backend.app.admin.model import User
class Dept(Base): class Dept(Base):
"""部门表""" """部门表"""
@@ -18,28 +11,13 @@ class Dept(Base):
__tablename__ = 'sys_dept' __tablename__ = 'sys_dept'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), comment='部门名称') name: Mapped[str] = mapped_column(sa.String(64), comment='部门名称')
sort: Mapped[int] = mapped_column(default=0, comment='排序') sort: Mapped[int] = mapped_column(default=0, comment='排序')
leader: Mapped[str | None] = mapped_column(String(20), default=None, comment='负责人') leader: Mapped[str | None] = mapped_column(sa.String(32), default=None, comment='负责人')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机') phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机')
email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱') email: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='邮箱')
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
del_flag: Mapped[bool] = mapped_column( del_flag: Mapped[bool] = mapped_column(default=False, comment='删除标志(0删除 1存在)')
Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='删除标志(0删除 1存在)',
)
# 父级部门一对多 # 父级部门
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父部门ID')
BigInteger,
ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父部门ID',
)
parent: Mapped[Dept | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list[Dept] | None] = relationship(init=False, back_populates='parent')
# 部门用户一对多
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
+14 -15
View File
@@ -1,11 +1,10 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import String import sqlalchemy as sa
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -15,18 +14,18 @@ class LoginLog(DataClassBase):
__tablename__ = 'sys_login_log' __tablename__ = 'sys_login_log'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID') user_uuid: Mapped[str] = mapped_column(sa.String(64), comment='用户UUID')
username: Mapped[str] = mapped_column(String(20), comment='用户名') username: Mapped[str] = mapped_column(sa.String(64), comment='用户名')
status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)') status: Mapped[int] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
ip: Mapped[str] = mapped_column(String(50), comment='登录IP地址') ip: Mapped[str] = mapped_column(sa.String(64), comment='登录IP地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家') country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区') region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市') city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头') user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统') os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器') browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备') device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息') msg: Mapped[str] = mapped_column(UniversalText, comment='提示消息')
login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间') login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
TimeZone, TimeZone,
+21 -35
View File
@@ -1,53 +1,39 @@
from sqlalchemy import BigInteger, Column, ForeignKey, Table import sqlalchemy as sa
from backend.common.model import MappedBase from backend.common.model import MappedBase
sys_user_role = Table( # 用户角色表
user_role = sa.Table(
'sys_user_role', 'sys_user_role',
MappedBase.metadata, MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), sa.Column('id', sa.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'), sa.Column('user_id', sa.BigInteger, primary_key=True, comment='用户ID'),
Column('role_id', BigInteger, ForeignKey('sys_role.id', ondelete='CASCADE'), primary_key=True, comment='角色ID'), sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
) )
sys_role_menu = Table( # 角色菜单表
role_menu = sa.Table(
'sys_role_menu', 'sys_role_menu',
MappedBase.metadata, MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), sa.Column('id', sa.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'), sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色ID'),
Column('menu_id', BigInteger, ForeignKey('sys_menu.id', ondelete='CASCADE'), primary_key=True, comment='菜单ID'), sa.Column('menu_id', sa.BigInteger, primary_key=True, comment='菜单ID'),
) )
sys_role_data_scope = Table( # 角色数据范围表
role_data_scope = sa.Table(
'sys_role_data_scope', 'sys_role_data_scope',
MappedBase.metadata, MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'), sa.Column('id', sa.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'), sa.Column('role_id', sa.BigInteger, primary_key=True, comment='角色 ID'),
Column( sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
'data_scope_id',
BigInteger,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'),
primary_key=True,
comment='数据范围 ID',
),
) )
sys_data_scope_rule = Table( # 数据范围规则表
data_scope_rule = sa.Table(
'sys_data_scope_rule', 'sys_data_scope_rule',
MappedBase.metadata, MappedBase.metadata,
Column('id', BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'), sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
Column( sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
'data_scope_id', sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 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',
),
) )
+13 -41
View File
@@ -1,17 +1,8 @@
from __future__ import annotations import sqlalchemy as sa
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, ForeignKey, String from backend.common.model import Base, UniversalText, id_key
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): class Menu(Base):
@@ -20,38 +11,19 @@ class Menu(Base):
__tablename__ = 'sys_menu' __tablename__ = 'sys_menu'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
title: Mapped[str] = mapped_column(String(50), comment='菜单标题') title: Mapped[str] = mapped_column(sa.String(64), comment='菜单标题')
name: Mapped[str] = mapped_column(String(50), comment='菜单名称') name: Mapped[str] = mapped_column(sa.String(64), comment='菜单名称')
path: Mapped[str | None] = mapped_column(String(200), comment='路由地址') path: Mapped[str | None] = mapped_column(sa.String(200), comment='路由地址')
sort: Mapped[int] = mapped_column(default=0, comment='排序') sort: Mapped[int] = mapped_column(default=0, comment='排序')
icon: Mapped[str | None] = mapped_column(String(100), default=None, comment='菜单图标') icon: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='菜单图标')
type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)') type: Mapped[int] = mapped_column(default=0, comment='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: Mapped[str | None] = mapped_column(String(255), default=None, comment='组件路径') component: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='组件路径')
perms: Mapped[str | None] = mapped_column(String(100), default=None, comment='权限标识') perms: Mapped[str | None] = mapped_column(sa.String(128), default=None, comment='权限标识')
status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='菜单状态(0停用 1正常)')
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)') display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)') cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column( link: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='外链地址')
LONGTEXT().with_variant(TEXT, 'postgresql'), remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
default=None,
comment='外链地址',
)
remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
# 父级菜单一对多 # 父级菜单
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='父菜单ID')
BigInteger,
ForeignKey('sys_menu.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父菜单ID',
)
parent: Mapped[Menu | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[list[Menu] | None] = relationship(init=False, back_populates='parent')
# 菜单角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
+20 -24
View File
@@ -1,11 +1,10 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import String import sqlalchemy as sa
from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -15,28 +14,25 @@ class OperaLog(DataClassBase):
__tablename__ = 'sys_opera_log' __tablename__ = 'sys_opera_log'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
trace_id: Mapped[str] = mapped_column(String(32), comment='请求跟踪 ID') trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
username: Mapped[str | None] = mapped_column(String(20), comment='用户名') username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
method: Mapped[str] = mapped_column(String(20), comment='请求类型') method: Mapped[str] = mapped_column(sa.String(32), comment='请求方法')
title: Mapped[str] = mapped_column(String(255), comment='操作模块') title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
path: Mapped[str] = mapped_column(String(500), comment='请求路径') path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
ip: Mapped[str] = mapped_column(String(50), comment='IP地址') ip: Mapped[str] = mapped_column(sa.String(64), comment='IP 地址')
country: Mapped[str | None] = mapped_column(String(50), comment='国家') country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区') region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市') city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头') user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='用户代理')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统') os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器') browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备') device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数') args: Mapped[str | None] = mapped_column(sa.JSON(), comment='请求参数')
status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)') status: Mapped[int] = mapped_column(comment='操作状态(0异常 1正常)')
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码') code: Mapped[str] = mapped_column(sa.String(32), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息') msg: Mapped[str | None] = mapped_column(UniversalText, comment='提示消息')
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms') cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms')
opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间') opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
TimeZone, TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
init=False,
default_factory=timezone.now,
comment='创建时间',
) )
+6 -32
View File
@@ -1,17 +1,8 @@
from __future__ import annotations import sqlalchemy as sa
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, String from backend.common.model import Base, UniversalText, id_key
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): class Role(Base):
@@ -20,24 +11,7 @@ class Role(Base):
__tablename__ = 'sys_role' __tablename__ = 'sys_role'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称') name: Mapped[str] = mapped_column(sa.String(32), unique=True, comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column( is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
Boolean().with_variant(INTEGER, 'postgresql'), remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
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')
+20 -47
View File
@@ -1,20 +1,13 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, ForeignKey, String import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship 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.common.model import Base, TimeZone, id_key
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
if TYPE_CHECKING:
from backend.app.admin.model import Dept, Role
class User(Base): class User(Base):
"""用户表""" """用户表"""
@@ -22,45 +15,25 @@ class User(Base):
__tablename__ = 'sys_user' __tablename__ = 'sys_user'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True) uuid: Mapped[str] = mapped_column(sa.String(64), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名') username: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(String(20), comment='昵称') nickname: Mapped[str] = mapped_column(sa.String(64), comment='昵称')
password: Mapped[str | None] = mapped_column(String(255), comment='密码') password: Mapped[str | None] = mapped_column(sa.String(256), comment='密码')
salt: Mapped[bytes | None] = mapped_column(VARBINARY(255).with_variant(BYTEA(255), 'postgresql'), comment='加密盐') salt: Mapped[bytes | None] = mapped_column(sa.LargeBinary(255), comment='加密盐')
email: Mapped[str | None] = mapped_column(String(50), default=None, unique=True, index=True, comment='邮箱') email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号') phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
avatar: Mapped[str | None] = mapped_column(String(255), 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正常)') status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_superuser: Mapped[bool] = mapped_column( is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
Boolean().with_variant(INTEGER, 'postgresql'), is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
default=False, is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
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是)',
)
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间') join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
last_login_time: Mapped[datetime | None] = mapped_column( last_login_time: Mapped[datetime | None] = mapped_column(
TimeZone, TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
init=False, )
onupdate=timezone.now, last_password_changed_time: Mapped[datetime | None] = mapped_column(
comment='上次登录', TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
) )
# 部门用户一对多 # 逻辑外键
dept_id: Mapped[int | None] = mapped_column( dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='部门关联ID')
ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
comment='部门关联ID',
)
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 -1
View File
@@ -6,6 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase): class GetCaptchaDetail(SchemaBase):
"""验证码详情""" """验证码详情"""
is_enabled: bool = Field(description='是否启用')
expire_seconds: int = Field(description='过期秒数')
uuid: str = Field(description='图片唯一标识') uuid: str = Field(description='图片唯一标识')
img_type: str = Field(description='图片类型')
image: str = Field(description='图片内容') image: str = Field(description='图片内容')
+9 -2
View File
@@ -34,7 +34,7 @@ class DeleteDataRuleParam(SchemaBase):
class GetDataRuleDetail(DataRuleSchemaBase): class GetDataRuleDetail(DataRuleSchemaBase):
"""数据规则详情""" """数据规则详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True, frozen=True)
id: int = Field(description='规则 ID') id: int = Field(description='规则 ID')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
@@ -45,4 +45,11 @@ class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情""" """数据规则可用模型字段详情"""
key: str = Field(description='字段名') key: str = Field(description='字段名')
comment: str = Field(description='字段评论') comment: str | None = Field(description='字段评论')
class GetDataRuleTemplateVariableDetail(SchemaBase):
"""数据规则可用模板变量详情"""
key: str = Field(description='变量标识')
comment: str = Field(description='变量描述')
+8 -1
View File
@@ -22,6 +22,13 @@ class UpdateDataScopeParam(DataScopeBase):
"""更新数据范围参数""" """更新数据范围参数"""
class CreateDataScopeRuleParam(SchemaBase):
"""创建数据范围规则参数"""
data_scope_id: int = Field(description='数据范围 ID')
data_rule_id: int = Field(description='数据规则 ID')
class UpdateDataScopeRuleParam(SchemaBase): class UpdateDataScopeRuleParam(SchemaBase):
"""更新数据范围规则参数""" """更新数据范围规则参数"""
@@ -47,4 +54,4 @@ class GetDataScopeDetail(DataScopeBase):
class GetDataScopeWithRelationDetail(GetDataScopeDetail): class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情""" """数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表') rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
+1 -1
View File
@@ -15,7 +15,7 @@ class LoginLogSchemaBase(SchemaBase):
country: str | None = Field(None, description='国家') country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区') region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市') city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理') user_agent: str | None = Field(description='用户代理')
browser: str | None = Field(None, description='浏览器') browser: str | None = Field(None, description='浏览器')
os: str | None = Field(None, description='操作系统') os: str | None = Field(None, description='操作系统')
device: str | None = Field(None, description='设备') device: str | None = Field(None, description='设备')
+102
View File
@@ -0,0 +1,102 @@
from pydantic import Field
from backend.common.schema import SchemaBase
class CpuInfo(SchemaBase):
"""CPU 信息"""
physical_num: int = Field(description='物理核心数')
logical_num: int = Field(description='逻辑核心数')
max_freq: float = Field(description='最大频率(MHz')
min_freq: float = Field(description='最小频率(MHz')
current_freq: float = Field(description='当前频率(MHz')
usage: float = Field(description='使用率(%')
class MemInfo(SchemaBase):
"""内存信息"""
total: float = Field(description='总容量(GB')
used: float = Field(description='已使用(GB')
free: float = Field(description='可用(GB')
usage: float = Field(description='使用率(%')
class SysInfo(SchemaBase):
"""系统信息"""
name: str = Field(description='主机名')
os: str = Field(description='操作系统')
ip: str = Field(description='IP 地址')
arch: str = Field(description='系统架构')
class DiskInfo(SchemaBase):
"""磁盘信息"""
dir: str = Field(description='挂载点')
device: str = Field(description='设备名称')
type: str = Field(description='文件系统类型')
total: str = Field(description='总容量')
used: str = Field(description='已使用')
free: str = Field(description='可用')
usage: str = Field(description='使用率(%')
class ServiceInfo(SchemaBase):
"""服务进程信息"""
name: str = Field(description='服务名称')
version: str = Field(description='版本')
home: str = Field(description='安装路径')
startup: str = Field(description='启动时间')
elapsed: str = Field(description='运行时长')
cpu_usage: str = Field(description='CPU 使用率')
mem_vms: str = Field(description='虚拟内存')
mem_rss: str = Field(description='物理内存')
mem_free: str = Field(description='可用内存')
class ServerMonitorInfo(SchemaBase):
"""服务器监控信息"""
cpu: CpuInfo = Field(description='CPU 信息')
mem: MemInfo = Field(description='内存信息')
sys: SysInfo = Field(description='系统信息')
disk: list[DiskInfo] = Field(description='磁盘信息')
service: ServiceInfo = Field(description='服务信息')
class RedisServerInfo(SchemaBase):
"""Redis 服务器信息"""
redis_version: str = Field(description='版本号')
redis_mode: str = Field(description='运行模式')
role: str = Field(description='节点角色')
tcp_port: str = Field(description='监听端口')
uptime: str = Field(description='运行时长')
connected_clients: str = Field(description='已连接客户端数')
blocked_clients: str = Field(description='阻塞客户端数')
used_memory_human: str = Field(description='已使用内存')
used_memory_rss_human: str = Field(description='RSS 内存')
maxmemory_human: str = Field(description='最大内存限制')
mem_fragmentation_ratio: str = Field(description='内存碎片率')
instantaneous_ops_per_sec: str = Field(description='每秒操作数')
total_commands_processed: str = Field(description='命令处理总数')
rejected_connections: str = Field(description='拒绝连接数')
keys_num: str = Field(description='键总数')
class RedisCommandStat(SchemaBase):
"""Redis 命令统计"""
name: str = Field(description='命令名称')
value: str = Field(description='调用次数')
class RedisMonitorInfo(SchemaBase):
"""Redis 监控信息"""
info: RedisServerInfo = Field(description='服务器信息')
stats: list[RedisCommandStat] = Field(description='命令统计')
+1 -1
View File
@@ -19,7 +19,7 @@ class OperaLogSchemaBase(SchemaBase):
country: str | None = Field(None, description='国家') country: str | None = Field(None, description='国家')
region: str | None = Field(None, description='地区') region: str | None = Field(None, description='地区')
city: str | None = Field(None, description='城市') city: str | None = Field(None, description='城市')
user_agent: str = Field(description='用户代理') user_agent: str | None = Field(description='用户代理')
os: str | None = Field(None, description='操作系统') os: str | None = Field(None, description='操作系统')
browser: str | None = Field(None, description='浏览器') browser: str | None = Field(None, description='浏览器')
device: str | None = Field(None, description='设备') device: str | None = Field(None, description='设备')
+16 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
from backend.app.admin.schema.data_scope import GetDataScopeDetail from backend.app.admin.schema.data_scope import GetDataScopeWithRelationDetail
from backend.app.admin.schema.menu import GetMenuDetail from backend.app.admin.schema.menu import GetMenuDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
@@ -31,12 +31,26 @@ class DeleteRoleParam(SchemaBase):
pks: list[int] = Field(description='角色 ID 列表') pks: list[int] = Field(description='角色 ID 列表')
class CreateRoleMenuParam(SchemaBase):
"""创建角色菜单参数"""
role_id: int = Field(description='角色 ID')
menu_id: int = Field(description='菜单 ID')
class UpdateRoleMenuParam(SchemaBase): class UpdateRoleMenuParam(SchemaBase):
"""更新角色菜单参数""" """更新角色菜单参数"""
menus: list[int] = Field(description='菜单 ID 列表') menus: list[int] = Field(description='菜单 ID 列表')
class CreateRoleScopeParam(SchemaBase):
"""创建角色数据范围参数"""
role_id: int = Field(description='角色 ID')
data_scope_id: int = Field(description='数据范围 ID')
class UpdateRoleScopeParam(SchemaBase): class UpdateRoleScopeParam(SchemaBase):
"""更新角色数据范围参数""" """更新角色数据范围参数"""
@@ -57,4 +71,4 @@ class GetRoleWithRelationDetail(GetRoleDetail):
"""角色关联详情""" """角色关联详情"""
menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表') menus: list[GetMenuDetail | None] = Field([], description='菜单详情列表')
scopes: list[GetDataScopeDetail | None] = Field([], description='数据范围列表') scopes: list[GetDataScopeWithRelationDetail | None] = Field([], description='数据范围列表')
+1
View File
@@ -30,6 +30,7 @@ class GetNewToken(AccessTokenBase):
class GetLoginToken(AccessTokenBase): class GetLoginToken(AccessTokenBase):
"""获取登录令牌""" """获取登录令牌"""
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
user: GetUserInfoDetail = Field(description='用户信息') user: GetUserInfoDetail = Field(description='用户信息')
+14 -7
View File
@@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Annotated, Any
from pydantic import ConfigDict, Field, HttpUrl, model_validator from pydantic import ConfigDict, Field, HttpUrl, PlainSerializer, model_validator
from typing_extensions import Self from typing_extensions import Self
from backend.app.admin.schema.dept import GetDeptDetail from backend.app.admin.schema.dept import GetDeptDetail
from backend.app.admin.schema.role import GetRoleWithRelationDetail from backend.app.admin.schema.role import GetRoleWithRelationDetail
from backend.common.enums import StatusType from backend.common.enums import StatusType
from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
class AuthSchemaBase(SchemaBase): class AuthSchemaBase(SchemaBase):
@@ -20,8 +20,8 @@ class AuthSchemaBase(SchemaBase):
class AuthLoginParam(AuthSchemaBase): class AuthLoginParam(AuthSchemaBase):
"""用户登录参数""" """用户登录参数"""
uuid: str = Field(description='验证码 UUID') uuid: str | None = Field(None, description='验证码 UUID')
captcha: str = Field(description='验证码') captcha: str | None = Field(None, description='验证码')
class AddUserParam(AuthSchemaBase): class AddUserParam(AuthSchemaBase):
@@ -34,13 +34,20 @@ class AddUserParam(AuthSchemaBase):
roles: list[int] = Field(description='角色 ID 列表') roles: list[int] = Field(description='角色 ID 列表')
class AddUserRoleParam(SchemaBase):
"""添加用户角色"""
user_id: int = Field(description='用户 ID')
role_id: int = Field(description='角色 ID')
class AddOAuth2UserParam(AuthSchemaBase): class AddOAuth2UserParam(AuthSchemaBase):
"""添加 OAuth2 用户参数""" """添加 OAuth2 用户参数"""
password: str | None = Field(None, description='密码') password: str | None = Field(None, description='密码')
nickname: str | None = Field(None, description='昵称') nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
avatar: HttpUrl | None = Field(None, description='头像地址') avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase): class ResetPasswordParam(SchemaBase):
@@ -57,7 +64,7 @@ class UserInfoSchemaBase(SchemaBase):
dept_id: int | None = Field(None, description='部门 ID') dept_id: int | None = Field(None, description='部门 ID')
username: str = Field(description='用户名') username: str = Field(description='用户名')
nickname: str = Field(description='昵称') nickname: str = Field(description='昵称')
avatar: HttpUrl | None = Field(None, description='头像地址') avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
email: CustomEmailStr | None = Field(None, description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号') phone: CustomPhoneNumber | None = Field(None, description='手机号')
@@ -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):
"""创建用户历史密码记录"""
+63 -42
View File
@@ -9,8 +9,10 @@ from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken
from backend.app.admin.schema.user import AuthLoginParam from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.login_log_service import login_log_service from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify
from backend.common.context import ctx from backend.common.context import ctx
from backend.common.enums import LoginLogStatusType from backend.common.enums import LoginLogStatusType, StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.i18n import t from backend.common.i18n import t
from backend.common.log import log from backend.common.log import log
@@ -21,11 +23,11 @@ from backend.common.security.jwt import (
create_refresh_token, create_refresh_token,
get_token, get_token,
jwt_decode, jwt_decode,
password_verify,
) )
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -33,7 +35,7 @@ class AuthService:
"""认证服务类""" """认证服务类"""
@staticmethod @staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> User: async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
""" """
验证用户名和密码 验证用户名和密码
@@ -46,15 +48,19 @@ class AuthService:
if not user: if not user:
raise errors.NotFoundError(msg='用户名或密码有误') raise errors.NotFoundError(msg='用户名或密码有误')
if user.password is None: await password_security_service.check_status(user.id, user.status)
raise errors.AuthorizationError(msg='用户名或密码有误')
if not password_verify(password, user.password): 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='用户名或密码有误') raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status: days_remaining = await password_security_service.check_password_expiry_status(
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') db, user.last_password_changed_time
)
return user await password_security_service.handle_login_success(user.id)
return user, days_remaining
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]: async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
""" """
@@ -64,15 +70,15 @@ class AuthService:
:param obj: 登录凭证 :param obj: 登录凭证
:return: :return:
""" """
user = await self.user_verify(db, obj.username, obj.password) user, _ = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
access_token = await create_access_token( access_token_data = await create_access_token(
user.id, user.id,
multi_login=user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
swagger=True, swagger=True,
) )
return access_token.access_token, user return access_token_data.access_token, user
async def login( async def login(
self, self,
@@ -86,7 +92,6 @@ class AuthService:
用户登录 用户登录
:param db: 数据库会话 :param db: 数据库会话
:param request: 请求对象
:param response: 响应对象 :param response: 响应对象
:param obj: 登录参数 :param obj: 登录参数
:param background_tasks: 后台任务 :param background_tasks: 后台任务
@@ -94,16 +99,21 @@ class AuthService:
""" """
user = None user = None
try: try:
user = await self.user_verify(db, obj.username, obj.password) await load_login_config(db)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}') if settings.LOGIN_CAPTCHA_ENABLED:
if not captcha_code: if not obj.uuid or not obj.captcha:
raise errors.RequestError(msg=t('error.captcha.expired')) raise errors.RequestError(msg=t('error.captcha.invalid'))
if captcha_code.lower() != obj.captcha.lower(): captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) if not captcha_code:
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}') 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.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await db.refresh(user)
access_token = await create_access_token( access_token_data = await create_access_token(
user.id, user.id,
multi_login=user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
@@ -115,16 +125,16 @@ class AuthService:
browser=ctx.browser, browser=ctx.browser,
device=ctx.device, device=ctx.device,
) )
refresh_token = await create_refresh_token( refresh_token_data = await create_refresh_token(
access_token.session_uuid, access_token_data.session_uuid,
user.id, user.id,
multi_login=user.is_multi_login, multi_login=user.is_multi_login,
) )
response.set_cookie( response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY, key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token.refresh_token, value=refresh_token_data.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS, max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(refresh_token.refresh_token_expire_time), expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
httponly=True, httponly=True,
) )
except errors.NotFoundError as e: except errors.NotFoundError as e:
@@ -132,10 +142,9 @@ class AuthService:
raise errors.NotFoundError(msg=e.msg) raise errors.NotFoundError(msg=e.msg)
except (errors.RequestError, errors.CustomError) as e: except (errors.RequestError, errors.CustomError) as e:
if not user: if not user:
log.error('登陆错误: 用户密码有误') log.error(f'登陆错误: {e.msg}')
task = BackgroundTask( task = BackgroundTask(
login_log_service.create, login_log_service.create,
db=db,
user_uuid=user.uuid if user else uuid4_str(), user_uuid=user.uuid if user else uuid4_str(),
username=obj.username, username=obj.username,
login_time=timezone.now(), login_time=timezone.now(),
@@ -149,7 +158,6 @@ class AuthService:
else: else:
background_tasks.add_task( background_tasks.add_task(
login_log_service.create, login_log_service.create,
db=db,
user_uuid=user.uuid, user_uuid=user.uuid,
username=obj.username, username=obj.username,
login_time=timezone.now(), login_time=timezone.now(),
@@ -157,9 +165,10 @@ class AuthService:
msg=t('success.login.success'), msg=t('success.login.success'),
) )
data = GetLoginToken( data = GetLoginToken(
access_token=access_token.access_token, access_token=access_token_data.access_token,
access_token_expire_time=access_token.access_token_expire_time, access_token_expire_time=access_token_data.access_token_expire_time,
session_uuid=access_token.session_uuid, session_uuid=access_token_data.session_uuid,
password_expire_days_remaining=days_remaining,
user=user, # type: ignore user=user, # type: ignore
) )
return data return data
@@ -177,38 +186,43 @@ class AuthService:
if request.user.is_superuser: if request.user.is_superuser:
menus = await menu_dao.get_all(db, None, None) menus = await menu_dao.get_all(db, None, None)
for menu in menus: for menu in menus:
if menu.perms: if menu.status == StatusType.enable and menu.perms:
codes.add(*menu.perms.split(',')) codes.update(menu.perms.split(','))
else: else:
roles = request.user.roles roles = [role for role in request.user.roles if role.status == StatusType.enable]
if roles: if roles:
for role in roles: for role in roles:
for menu in role.menus: for menu in role.menus:
if menu.perms: if menu.status == StatusType.enable and menu.perms:
codes.add(*menu.perms.split(',')) codes.update(menu.perms.split(','))
return list(codes) return list(codes)
@staticmethod @staticmethod
async def refresh_token(*, db: AsyncSession, request: Request) -> GetNewToken: async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
""" """
刷新令牌 刷新令牌
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:param response: FastAPI 响应对象
:return: :return:
""" """
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
if not refresh_token: if not refresh_token:
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录') raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
token_payload = jwt_decode(refresh_token)
user = await user_dao.get(db, token_payload.id) token_payload = jwt_decode(refresh_token)
user = await user_dao.get(db, token_payload.user_id)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if not user.status: if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
if not user.is_multi_login and await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'): if not user.is_multi_login and [
key
for key in await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*')
if not key.endswith(f':{token_payload.session_uuid}')
]:
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码') raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token( new_token = await create_new_token(
refresh_token, refresh_token,
@@ -224,6 +238,13 @@ class AuthService:
browser=ctx.browser, browser=ctx.browser,
device_type=ctx.device, 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( data = GetNewToken(
access_token=new_token.new_access_token, access_token=new_token.new_access_token,
access_token_expire_time=new_token.new_access_token_expire_time, access_token_expire_time=new_token.new_access_token_expire_time,
@@ -243,7 +264,7 @@ class AuthService:
try: try:
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id user_id = token_payload.user_id
session_uuid = token_payload.session_uuid session_uuid = token_payload.session_uuid
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError: except errors.TokenError:
@@ -254,7 +275,7 @@ class AuthService:
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}') await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}') await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if refresh_token: if refresh_token:
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}') await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
auth_service: AuthService = AuthService() auth_service: AuthService = AuthService()
+32 -7
View File
@@ -1,6 +1,7 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from sqlalchemy import Table
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao from backend.app.admin.crud.crud_data_rule import data_rule_dao
@@ -9,12 +10,14 @@ from backend.app.admin.schema.data_rule import (
CreateDataRuleParam, CreateDataRuleParam,
DeleteDataRuleParam, DeleteDataRuleParam,
GetDataRuleColumnDetail, GetDataRuleColumnDetail,
GetDataRuleTemplateVariableDetail,
UpdateDataRuleParam, UpdateDataRuleParam,
) )
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.common.security.permission import get_data_permission_models
from backend.core.conf import settings from backend.core.conf import settings
from backend.utils.import_parse import dynamic_import_data_model
class DataRuleService: class DataRuleService:
@@ -38,7 +41,16 @@ class DataRuleService:
@staticmethod @staticmethod
async def get_models() -> list[str]: async def get_models() -> list[str]:
"""获取所有数据规则可用模型""" """获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys()) model_template_variables = [var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES]
models = [
m for m in list(get_data_permission_models().keys()) if m not in settings.DATA_PERMISSION_MODEL_EXCLUDE
]
return model_template_variables + models
@staticmethod
async def get_value_template_variables() -> list[GetDataRuleTemplateVariableDetail]:
"""获取所有数据规则值可用模板变量"""
return [GetDataRuleTemplateVariableDetail(**var) for var in settings.DATA_PERMISSION_TEMPLATE_VARIABLES]
@staticmethod @staticmethod
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]: async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
@@ -48,16 +60,27 @@ class DataRuleService:
:param model: 模型名称 :param model: 模型名称
:return: :return:
""" """
if model not in settings.DATA_PERMISSION_MODELS: column_template_variables = [
raise errors.NotFoundError(msg='数据规则可用模型不存在') GetDataRuleColumnDetail(key=var['key'], comment=var['comment'])
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) for var in settings.DATA_PERMISSION_COLUMN_TEMPLATE_VARIABLES
]
model_template_variable_keys = {var['key'] for var in settings.DATA_PERMISSION_MODEL_TEMPLATE_VARIABLES}
if model in model_template_variable_keys:
return column_template_variables
available_models = get_data_permission_models()
if model not in available_models:
raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = available_models[model]
table = model_ins if isinstance(model_ins, Table) else model_ins.__table__
model_columns = [ model_columns = [
GetDataRuleColumnDetail(key=column.key, comment=column.comment) GetDataRuleColumnDetail(key=column.key, comment=column.comment)
for column in model_ins.__table__.columns for column in table.columns
if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE if column.key not in settings.DATA_PERMISSION_COLUMN_EXCLUDE
] ]
return model_columns return model_columns + column_template_variables
@staticmethod @staticmethod
async def get_list(*, db: AsyncSession, name: str | None) -> dict[str, Any]: async def get_list(*, db: AsyncSession, name: str | None) -> dict[str, Any]:
@@ -113,6 +136,7 @@ class DataRuleService:
if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name): if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据规则已存在') raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj) count = await data_rule_dao.update(db, pk, obj)
await user_cache_manager.clear_by_data_rule_id(db, [pk])
return count return count
@staticmethod @staticmethod
@@ -125,6 +149,7 @@ class DataRuleService:
:return: :return:
""" """
count = await data_rule_dao.delete(db, obj.pks) count = await data_rule_dao.delete(db, obj.pks)
await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
return count return count
+14 -12
View File
@@ -3,6 +3,7 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.model import DataScope from backend.app.admin.model import DataScope
from backend.app.admin.schema.data_scope import ( from backend.app.admin.schema.data_scope import (
@@ -11,10 +12,9 @@ from backend.app.admin.schema.data_scope import (
UpdateDataScopeParam, UpdateDataScopeParam,
UpdateDataScopeRuleParam, UpdateDataScopeRuleParam,
) )
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.core.conf import settings
from backend.database.redis import redis_client
class DataScopeService: class DataScopeService:
@@ -57,7 +57,7 @@ class DataScopeService:
:return: :return:
""" """
data_scope = await data_scope_dao.get_with_relation(db, pk) data_scope = await data_scope_dao.get_join(db, pk)
if not data_scope: if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
return data_scope return data_scope
@@ -105,9 +105,7 @@ class DataScopeService:
if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name): if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='数据范围已存在') raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj) count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.awaitable_attrs.roles: await user_cache_manager.clear_by_data_scope_id(db, [pk])
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -115,11 +113,20 @@ class DataScopeService:
""" """
更新数据范围规则 更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID :param pk: 范围 ID
:param rule_ids: 规则 ID 列表 :param rule_ids: 规则 ID 列表
:return: :return:
""" """
data_scope = await data_scope_dao.get(db, pk)
if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在')
for rule_id in rule_ids.rules:
rule = await data_rule_dao.get(db, rule_id)
if not rule:
raise errors.NotFoundError(msg='数据规则不存在')
count = await data_scope_dao.update_rules(db, pk, rule_ids) count = await data_scope_dao.update_rules(db, pk, rule_ids)
await user_cache_manager.clear_by_data_scope_id(db, [pk])
return count return count
@staticmethod @staticmethod
@@ -132,12 +139,7 @@ class DataScopeService:
:return: :return:
""" """
count = await data_scope_dao.delete(db, obj.pks) count = await data_scope_dao.delete(db, obj.pks)
for pk in obj.pks: await user_cache_manager.clear_by_data_scope_id(db, 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 return count
+8 -11
View File
@@ -1,14 +1,12 @@
from typing import Any from typing import Any
from fastapi import Request from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.model import Dept from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, UpdateDeptParam
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data from backend.utils.build_tree import get_tree_data
@@ -34,7 +32,7 @@ class DeptService:
async def get_tree( async def get_tree(
*, *,
db: AsyncSession, db: AsyncSession,
request: Request, data_filter: ColumnElement[bool],
name: str | None, name: str | None,
leader: str | None, leader: str | None,
phone: str | None, phone: str | None,
@@ -44,15 +42,14 @@ class DeptService:
获取部门树形结构 获取部门树形结构
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param data_filter: 请求用户
:param name: 部门名称 :param name: 部门名称
:param leader: 部门负责人 :param leader: 部门负责人
:param phone: 联系电话 :param phone: 联系电话
:param status: 状态 :param status: 状态
:return: :return:
""" """
dept_select = await dept_dao.get_all(db, data_filter, name, leader, phone, status)
dept_select = await dept_dao.get_all(request, db, name, leader, phone, status)
tree_data = get_tree_data(dept_select) tree_data = get_tree_data(dept_select)
return tree_data return tree_data
@@ -68,7 +65,7 @@ class DeptService:
dept = await dept_dao.get_by_name(db, obj.name) dept = await dept_dao.get_by_name(db, obj.name)
if dept: if dept:
raise errors.ConflictError(msg='部门名称已存在') raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id: if obj.parent_id is not None:
parent_dept = await dept_dao.get(db, obj.parent_id) parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept: if not parent_dept:
raise errors.NotFoundError(msg='父级部门不存在') raise errors.NotFoundError(msg='父级部门不存在')
@@ -107,15 +104,15 @@ class DeptService:
:param pk: 部门 ID :param pk: 部门 ID
:return: :return:
""" """
dept = await dept_dao.get_with_relation(db, pk) dept = await dept_dao.get_join(db, pk)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
if dept.users: if dept.users:
raise errors.ConflictError(msg='部门下存在用户,无法删除') raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk) children = await dept_dao.get_children(db, pk)
if children: if children:
raise errors.ConflictError(msg='部门下存在子部门,无法删除') raise errors.ConflictError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk) 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 return count
@@ -8,6 +8,7 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginL
from backend.common.context import ctx from backend.common.context import ctx
from backend.common.log import log from backend.common.log import log
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.database.db import async_db_session
class LoginLogService: class LoginLogService:
@@ -30,7 +31,6 @@ class LoginLogService:
@staticmethod @staticmethod
async def create( async def create(
*, *,
db: AsyncSession,
user_uuid: str, user_uuid: str,
username: str, username: str,
login_time: datetime, login_time: datetime,
@@ -40,7 +40,6 @@ class LoginLogService:
""" """
创建登录日志 创建登录日志
:param db: 数据库会话
:param user_uuid: 用户 UUID :param user_uuid: 用户 UUID
:param username: 用户名 :param username: 用户名
:param login_time: 登录时间 :param login_time: 登录时间
@@ -64,7 +63,9 @@ class LoginLogService:
msg=msg, msg=msg,
login_time=login_time, login_time=login_time,
) )
await login_log_dao.create(db, obj) # 为后台任务创建独立数据库会话
async with async_db_session.begin() as db:
await login_log_dao.create(db, obj)
except Exception as e: except Exception as e:
log.error(f'登录日志创建失败: {e}') log.error(f'登录日志创建失败: {e}')
+12 -14
View File
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_menu import menu_dao from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.model import Menu from backend.app.admin.model import Menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.enums import StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
@@ -54,18 +54,21 @@ class MenuService:
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
menu_data = None
if request.user.is_superuser: if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None) menu_data = await menu_dao.get_sidebar(db, None)
else: else:
roles = request.user.roles roles = [role for role in request.user.roles if role.status == StatusType.enable]
menu_ids = set() menu_ids = set()
if roles: if roles:
for role in roles: for role in roles:
menu_ids.update(menu.id for menu in role.menus) menu_ids.update(menu.id for menu in role.menus)
menu_data = await menu_dao.get_sidebar(db, list(menu_ids)) menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
menu_tree = get_vben5_tree_data(menu_data)
return menu_tree if menu_data:
return get_vben5_tree_data(menu_data)
return []
@staticmethod @staticmethod
async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None: async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
@@ -109,9 +112,7 @@ class MenuService:
if obj.parent_id == menu.id: if obj.parent_id == menu.id:
raise errors.ForbiddenError(msg='禁止关联自身为父级') raise errors.ForbiddenError(msg='禁止关联自身为父级')
count = await menu_dao.update(db, pk, obj) count = await menu_dao.update(db, pk, obj)
for role in await menu.awaitable_attrs.roles: await user_cache_manager.clear_by_menu_id(db, [pk])
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -127,12 +128,9 @@ class MenuService:
children = await menu_dao.get_children(db, pk) children = await menu_dao.get_children(db, pk)
if children: if children:
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除') raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk) count = await menu_dao.delete(db, pk)
if menu: if count:
for role in await menu.awaitable_attrs.roles: await user_cache_manager.clear_by_menu_id(db, [pk])
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+31 -20
View File
@@ -1,22 +1,21 @@
import io import io
import json import json
import os
import shutil
import zipfile
from typing import Any from typing import Any
import anyio import anyio
from fastapi import UploadFile from fastapi import UploadFile
from starlette.concurrency import run_in_threadpool
from backend.common.enums import PluginType, StatusType from backend.common.enums import PluginType, StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR from backend.core.path_conf import PLUGIN_DIR
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.plugin.tools import uninstall_requirements_async from backend.plugin.core import get_required_plugins
from backend.utils.file_ops import install_git_plugin, install_zip_plugin 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 from backend.utils.timezone import timezone
@@ -27,9 +26,19 @@ class PluginService:
async def get_all() -> list[dict[str, Any]]: async def get_all() -> list[dict[str, Any]]:
"""获取所有插件""" """获取所有插件"""
keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*')] changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*') if key != changed_key]
if not keys:
return []
result = [json.loads(info) for info in await redis_client.mget(*keys)] result = []
for info in await redis_client.mget(*keys):
if info is None:
continue
plugin_info = json.loads(info)
if isinstance(plugin_info, dict):
result.append(plugin_info)
return result return result
@@ -48,6 +57,8 @@ class PluginService:
:param repo_url: git 仓库地址 :param repo_url: git 仓库地址
:return: :return:
""" """
if settings.ENVIRONMENT != 'dev':
raise errors.RequestError(msg='禁止在非开发环境下安装插件')
if type == PluginType.zip: if type == PluginType.zip:
if not file: if not file:
raise errors.RequestError(msg='ZIP 压缩包不能为空') raise errors.RequestError(msg='ZIP 压缩包不能为空')
@@ -64,14 +75,19 @@ class PluginService:
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
if settings.ENVIRONMENT != 'dev':
raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
if plugin in get_required_plugins():
raise errors.RequestError(msg=f'插件 {plugin} 为必需插件,禁止卸载')
plugin_dir = anyio.Path(PLUGIN_DIR / plugin) plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not await plugin_dir.exists(): if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin) await uninstall_requirements_async(plugin)
bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup' backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip'
shutil.move(plugin_dir, bacup_dir) 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.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 @staticmethod
async def update_status(*, plugin: str) -> None: async def update_status(*, plugin: str) -> None:
@@ -81,7 +97,8 @@ class PluginService:
:param plugin: 插件名称 :param plugin: 插件名称
:return: :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: if not plugin_info:
raise errors.NotFoundError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
plugin_info = json.loads(plugin_info) plugin_info = json.loads(plugin_info)
@@ -93,7 +110,8 @@ class PluginService:
else str(StatusType.disable.value) else str(StatusType.disable.value)
) )
plugin_info['plugin']['enable'] = new_status 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 @staticmethod
async def build(*, plugin: str) -> io.BytesIO: async def build(*, plugin: str) -> io.BytesIO:
@@ -108,14 +126,7 @@ class PluginService:
raise errors.NotFoundError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO() bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf: await run_in_threadpool(zip_plugin, plugin_dir, bio)
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) # noqa: ASYNC240
zf.write(file_path, os.path.join(plugin, arcname))
bio.seek(0) bio.seek(0)
return bio return bio
+10 -17
View File
@@ -14,10 +14,9 @@ from backend.app.admin.schema.role import (
UpdateRoleParam, UpdateRoleParam,
UpdateRoleScopeParam, UpdateRoleScopeParam,
) )
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data from backend.utils.build_tree import get_tree_data
@@ -34,7 +33,7 @@ class RoleService:
:return: :return:
""" """
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_join(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
return role return role
@@ -74,10 +73,11 @@ class RoleService:
:return: :return:
""" """
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
menu_tree = get_tree_data(role.menus) if role.menus else [] menus = await role_dao.get_menus(db, pk)
menu_tree = get_tree_data(menus) if menus else []
return menu_tree return menu_tree
@staticmethod @staticmethod
@@ -90,7 +90,7 @@ class RoleService:
:return: :return:
""" """
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get_join(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
scope_ids = [scope.id for scope in role.scopes] scope_ids = [scope.id for scope in role.scopes]
@@ -128,8 +128,7 @@ class RoleService:
if role.name != obj.name and await role_dao.get_by_name(db, obj.name): if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='角色已存在') raise errors.ConflictError(msg='角色已存在')
count = await role_dao.update(db, pk, obj) count = await role_dao.update(db, pk, obj)
for user in await role.awaitable_attrs.users: await user_cache_manager.clear_by_role_id(db, [pk])
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -151,8 +150,7 @@ class RoleService:
if not menu: if not menu:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
count = await role_dao.update_menus(db, pk, menu_ids) count = await role_dao.update_menus(db, pk, menu_ids)
for user in await role.awaitable_attrs.users: await user_cache_manager.clear_by_role_id(db, [pk])
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -174,8 +172,7 @@ class RoleService:
if not scope: if not scope:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
count = await role_dao.update_scopes(db, pk, scope_ids) count = await role_dao.update_scopes(db, pk, scope_ids)
for user in await role.awaitable_attrs.users: await user_cache_manager.clear_by_role_id(db, [pk])
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -189,11 +186,7 @@ class RoleService:
""" """
count = await role_dao.delete(db, obj.pks) count = await role_dao.delete(db, obj.pks)
for pk in obj.pks: await user_cache_manager.clear_by_role_id(db, 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 return count
@@ -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.setex(
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
settings.USER_LOCK_SECONDS,
str(failure_count),
)
if failure_count >= settings.USER_LOCK_THRESHOLD:
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
await redis_client.setex(
f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}',
settings.USER_LOCK_SECONDS,
timezone.to_str(locked_until),
)
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()
+58 -51
View File
@@ -1,5 +1,3 @@
import random
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
@@ -15,14 +13,18 @@ from backend.app.admin.schema.user import (
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
) )
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify, validate_new_password
from backend.common.context import ctx from backend.common.context import ctx
from backend.common.enums import UserPermissionType from backend.common.enums import UserPermissionType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.common.response.response_code import CustomErrorCode from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode, password_verify from backend.common.security.jwt import get_token, jwt_decode
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize
class UserService: class UserService:
@@ -38,7 +40,7 @@ class UserService:
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
user = await user_dao.get_with_relation(db, user_id=pk, username=username) user = await user_dao.get_join(db, user_id=pk, username=username)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
return user return user
@@ -52,7 +54,7 @@ class UserService:
:param pk: 用户 ID :param pk: 用户 ID
:return: :return:
""" """
user = await user_dao.get_with_relation(db, user_id=pk) user = await user_dao.get_join(db, user_id=pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
return user.roles return user.roles
@@ -70,7 +72,12 @@ class UserService:
:return: :return:
""" """
user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status) user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
return await paging_data(db, user_select) data = await paging_data(db, user_select)
if data['items']:
serialized_items = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
# 确保返回的是列表,即使只有一个元素
data['items'] = [serialized_items] if not isinstance(serialized_items, list) else serialized_items
return data
@staticmethod @staticmethod
async def create(*, db: AsyncSession, obj: AddUserParam) -> None: async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
@@ -83,7 +90,6 @@ class UserService:
""" """
if await user_dao.get_by_username(db, obj.username): if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册') raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
if not obj.password: if not obj.password:
raise errors.RequestError(msg='密码不允许为空') raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id): if not await dept_dao.get(db, obj.dept_id):
@@ -91,6 +97,7 @@ class UserService:
for role_id in obj.roles: for role_id in obj.roles:
if not await role_dao.get(db, role_id): if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
obj.nickname = obj.nickname or obj.username
await user_dao.add(db, obj) await user_dao.add(db, obj)
@staticmethod @staticmethod
@@ -103,15 +110,17 @@ class UserService:
:param obj: 用户更新参数 :param obj: 用户更新参数
:return: :return:
""" """
user = await user_dao.get_with_relation(db, user_id=pk) user = await user_dao.get_join(db, user_id=pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username and await user_dao.get_by_username(db, obj.username): if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册') raise errors.ConflictError(msg='用户名已注册')
if obj.dept_id and obj.dept_id != user.dept_id and not await dept_dao.get(db, dept_id=obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles: for role_id in obj.roles:
if not await role_dao.get(db, role_id): if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user, obj) count = await user_dao.update(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@@ -133,7 +142,7 @@ class UserService:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限') raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_super(db, pk, is_super=not user.status) count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
case UserPermissionType.staff: case UserPermissionType.staff:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
@@ -189,104 +198,101 @@ class UserService:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
await validate_new_password(db, user.id, password)
count = await user_dao.reset_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)
key_prefix = [ key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}', f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
] ]
for prefix in key_prefix: for prefix in key_prefix:
await redis_client.delete(prefix) await redis_client.delete_prefix(prefix)
return count return count
@staticmethod @staticmethod
async def update_nickname(*, db: AsyncSession, request: Request, nickname: str) -> int: async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
""" """
更新当前用户昵称 更新当前用户昵称
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param user_id: 用户 ID
:param nickname: 用户昵称 :param nickname: 用户昵称
:return: :return:
""" """
token = get_token(request) count = await user_dao.update_nickname(db, user_id, nickname)
token_payload = jwt_decode(token) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
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 return count
@staticmethod @staticmethod
async def update_avatar(*, db: AsyncSession, request: Request, avatar: str) -> int: async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
""" """
更新当前用户头像 更新当前用户头像
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param user_id: 用户 ID
:param avatar: 头像地址 :param avatar: 头像地址
:return: :return:
""" """
token = get_token(request) count = await user_dao.update_avatar(db, user_id, avatar)
token_payload = jwt_decode(token) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
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 return count
@staticmethod @staticmethod
async def update_email(*, db: AsyncSession, request: Request, captcha: str, email: str) -> int: async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
""" """
更新当前用户邮箱 更新当前用户邮箱
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param user_id: 用户 ID
:param captcha: 邮箱验证码 :param captcha: 邮箱验证码
:param email: 邮箱 :param email: 邮箱
:return: :return:
""" """
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}:{ctx.ip}') captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
if not captcha_code: if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取') raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code: if captcha != captcha_code:
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}') await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
count = await user_dao.update_email(db, token_payload.id, email) count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count return count
@staticmethod @staticmethod
async def update_password(*, db: AsyncSession, request: Request, obj: ResetPasswordParam) -> int: async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
""" """
更新当前用户密码 更新当前用户密码
:param db: 数据库会话 :param db: 数据库会话
:param request: FastAPI 请求对象 :param user_id: 用户 ID
:param obj: 密码重置参数 :param obj: 密码重置参数
:return: :return:
""" """
token = get_token(request) user = await user_dao.get(db, user_id)
token_payload = jwt_decode(token)
user = await user_dao.get(db, token_payload.id) if user.password and not password_verify(obj.old_password, user.password):
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误') raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password: if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='密码输入不一致') raise errors.RequestError(msg='两次密码输入不一致')
count = await user_dao.reset_password(db, user.id, obj.new_password)
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)
key_prefix = [ key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}', f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
] ]
for prefix in key_prefix: for prefix in key_prefix:
await redis_client.delete_prefix(prefix) await redis_client.delete_prefix(prefix)
@@ -308,6 +314,7 @@ class UserService:
key_prefix = [ key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}', f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
] ]
for key in key_prefix: for key in key_prefix:
await redis_client.delete_prefix(key) await redis_client.delete_prefix(key)
-15
View File
@@ -1,15 +0,0 @@
from collections.abc 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
+98
View File
@@ -0,0 +1,98 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.model import data_scope_rule, role_data_scope, role_menu, user_role
from backend.core.conf import settings
from backend.database.redis import redis_client
class UserCacheManager:
"""用户缓存管理"""
@staticmethod
async def clear(user_ids: Sequence[int]) -> None:
"""
清理用户缓存
:param user_ids: 用户 ID 列表
:return:
"""
if user_ids:
await redis_client.delete(*[f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}' for user_id in user_ids])
async def clear_by_role_id(self, db: AsyncSession, role_ids: list[int]) -> None:
"""
通过角色 ID 清理用户缓存
:param db: 数据库会话
:param role_ids: 角色 ID 列表
:return:
"""
stmt = select(user_role.c.user_id).where(user_role.c.role_id.in_(role_ids)).distinct()
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_menu_id(self, db: AsyncSession, menu_ids: list[int]) -> None:
"""
通过菜单 ID 清理用户缓存
:param db: 数据库会话
:param menu_ids: 菜单 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_menu, user_role.c.role_id == role_menu.c.role_id)
.where(role_menu.c.menu_id.in_(menu_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_scope_id(self, db: AsyncSession, scope_ids: list[int]) -> None:
"""
通过数据范围 ID 清理用户缓存
:param db: 数据库会话
:param scope_ids: 数据范围 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.where(role_data_scope.c.data_scope_id.in_(scope_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
async def clear_by_data_rule_id(self, db: AsyncSession, rule_ids: list[int]) -> None:
"""
通过数据规则 ID 清理用户缓存
:param db: 数据库会话
:param rule_ids: 数据规则 ID 列表
:return:
"""
stmt = (
select(user_role.c.user_id)
.join(role_data_scope, user_role.c.role_id == role_data_scope.c.role_id)
.join(data_scope_rule, role_data_scope.c.data_scope_id == data_scope_rule.c.data_scope_id)
.where(data_scope_rule.c.data_rule_id.in_(rule_ids))
.distinct()
)
result = await db.execute(stmt)
user_ids = result.scalars().all()
await self.clear(user_ids)
user_cache_manager: UserCacheManager = UserCacheManager()
@@ -0,0 +1,68 @@
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.common.exception import errors
from backend.core.conf import settings
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
password_hash = PasswordHash((BcryptHasher(),))
def get_hash_password(password: str, salt: bytes | None) -> str:
"""
使用哈希算法加密密码
:param password: 密码
:param salt: 盐值
:return:
"""
return password_hash.hash(password, salt=salt)
def password_verify(plain_password: str, hashed_password: str) -> bool:
"""
密码验证
:param plain_password: 待验证的密码
:param hashed_password: 哈希密码
:return:
"""
return password_hash.verify(plain_password, hashed_password)
async def validate_new_password(db: AsyncSession, user_id: int, new_password: str) -> None:
"""
验证新密码
:param db: 数据库会话
:param user_id: 用户ID
:param new_password: 新密码
:return:
"""
await load_user_security_config(db)
if len(new_password) < settings.USER_PASSWORD_MIN_LENGTH:
raise errors.RequestError(msg=f'密码长度不能少于 {settings.USER_PASSWORD_MIN_LENGTH} 个字符')
if len(new_password) > settings.USER_PASSWORD_MAX_LENGTH:
raise errors.RequestError(msg=f'密码长度不能超过 {settings.USER_PASSWORD_MAX_LENGTH} 个字符')
if not is_has_number(new_password):
raise errors.RequestError(msg='密码必须包含数字')
if not is_has_letter(new_password):
raise errors.RequestError(msg='密码必须包含字母')
if settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR and not is_has_special_char(new_password):
raise errors.RequestError(msg='密码必须包含特殊字符(如:!@#$%')
password_history = await user_password_history_dao.get_by_user_id(db, user_id)
for hist in password_history[: settings.USER_PASSWORD_HISTORY_CHECK_COUNT]:
if password_verify(new_password, hist.password):
raise errors.RequestError(
msg=f'新密码不能与最近 {settings.USER_PASSWORD_HISTORY_CHECK_COUNT} 次使用的密码相同'
)
+2 -3
View File
@@ -1,7 +1,7 @@
## 任务介绍 ## 任务介绍
当前任务使用 Celery 当前任务使用 Celery
实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225) 实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi-best-architecture/discussions/225)
## 定时任务 ## 定时任务
@@ -16,8 +16,7 @@
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是 如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
1. 在 `backend/app/task/tasks` 目录下新建 python 包目录 1. 在 `backend/app/task/tasks` 目录下新建 python 包目录
2. 新建目录,务必更新 `conf.py` 配置中的 `CELERY_TASKS_PACKAGES`,将新建目录模块路径添加到此列表 2. 新建目录,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
3. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
## 消息代理 ## 消息代理
+3 -4
View File
@@ -1,8 +1,7 @@
import sys import sys
from backend.core.path_conf import BASE_PATH from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
from .actions import * # noqa: F403 from .actions import * # noqa: F403
# 导入项目根目录
sys.path.append(str(BASE_PATH.parent))
+3 -3
View File
@@ -5,8 +5,8 @@ from backend.app.task.api.v1.result import router as task_result_router
from backend.app.task.api.v1.scheduler import router as task_scheduler_router from backend.app.task.api.v1.scheduler import router as task_scheduler_router
from backend.core.conf import settings from backend.core.conf import settings
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/tasks', tags=['任务']) v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH, tags=['任务'])
v1.include_router(task_control_router) v1.include_router(task_control_router, prefix='/tasks')
v1.include_router(task_result_router, prefix='/results') v1.include_router(task_result_router, prefix='/task-results')
v1.include_router(task_scheduler_router, prefix='/schedulers') v1.include_router(task_scheduler_router, prefix='/schedulers')
+2 -3
View File
@@ -26,10 +26,9 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail
for task in tasks: for task in tasks:
task_ins = celery_app_tasks.get(task) task_ins = celery_app_tasks.get(task)
if task_ins: if task_ins:
task_doc = task_ins.__doc__ task_registered.append(TaskRegisteredDetail(name=task_ins.__doc__ or task, task=task))
task_registered.append({'name': task_doc or task_ins, 'task': task_ins})
else: else:
task_registered.append({'name': task, 'task': task}) task_registered.append(TaskRegisteredDetail(name=task, task=task))
return response_base.success(data=task_registered) return response_base.success(data=task_registered)
+3 -3
View File
@@ -43,7 +43,7 @@ async def get_task_scheduler(
) )
async def get_task_scheduler_paginated( async def get_task_scheduler_paginated(
db: CurrentSession, db: CurrentSession,
name: Annotated[int | None, Path(description='任务调度名称')] = None, name: Annotated[str | None, Query(description='任务调度名称')] = None,
type: Annotated[int | None, Query(description='任务调度类型')] = None, type: Annotated[int | None, Query(description='任务调度类型')] = None,
) -> ResponseSchemaModel[PageData[GetTaskSchedulerDetail]]: ) -> ResponseSchemaModel[PageData[GetTaskSchedulerDetail]]:
page_data = await task_scheduler_service.get_list(db=db, name=name, type=type) page_data = await task_scheduler_service.get_list(db=db, name=name, type=type)
@@ -117,8 +117,8 @@ async def delete_task_scheduler(
@router.post( @router.post(
'/{pk}/executions', '/{pk}/execute',
summary='手动执行任务', summary='执行任务',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:task:exec')), Depends(RequestPermission('sys:task:exec')),
DependsRBAC, DependsRBAC,
+25 -6
View File
@@ -1,13 +1,25 @@
import os import os
import urllib.parse
import celery import celery
import celery_aio_pool import celery_aio_pool
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE
from backend.common.enums import DataBaseType
from backend.core.conf import settings from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH from backend.core.path_conf import BASE_PATH
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs) -> None:
"""初始化 Celery 追踪"""
if settings.GRAFANA_METRICS_ENABLE:
CeleryInstrumentor().instrument()
def find_task_packages() -> list[str]: def find_task_packages() -> list[str]:
packages = [] packages = []
task_dir = BASE_PATH / 'app' / 'task' / 'tasks' task_dir = BASE_PATH / 'app' / 'task' / 'tasks'
@@ -22,20 +34,25 @@ def init_celery() -> celery.Celery:
"""初始化 Celery 应用""" """初始化 Celery 应用"""
# TODO: Update this work if celery version >= 6.0.0 # TODO: Update this work if celery version >= 6.0.0
# https://github.com/fastapi-practices/fastapi_best_architecture/issues/321 # https://github.com/fastapi-practices/fastapi-best-architecture/issues/321
# https://github.com/celery/celery/issues/7874 # https://github.com/celery/celery/issues/7874
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations() celery.app.trace.reset_worker_optimizations()
broker_url = f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{urllib.parse.quote(settings.CELERY_RABBITMQ_PASSWORD)}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}/{settings.CELERY_RABBITMQ_VHOST}'
if settings.CELERY_BROKER == 'redis':
broker_url = f'redis://:{urllib.parse.quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
result_backend = f'db+postgresql+psycopg://{settings.DATABASE_USER}:{urllib.parse.quote(settings.DATABASE_PASSWORD)}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}'
if DataBaseType.mysql == settings.DATABASE_TYPE:
result_backend = result_backend.replace('postgresql+psycopg', 'mysql+pymysql')
# https://docs.celeryq.dev/en/stable/userguide/configuration.html # https://docs.celeryq.dev/en/stable/userguide/configuration.html
app = celery.Celery( app = celery.Celery(
'fba_celery', 'fba_celery',
broker_url=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}' broker_url=broker_url,
if settings.CELERY_BROKER == 'redis'
else f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}',
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
result_backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}' result_backend=result_backend,
f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}',
result_extended=True, result_extended=True,
database_engine_options={'echo': settings.DATABASE_ECHO}, database_engine_options={'echo': settings.DATABASE_ECHO},
# result_expires=0, # result_expires=0,
@@ -46,6 +63,8 @@ def init_celery() -> celery.Celery:
task_track_started=True, task_track_started=True,
enable_utc=False, enable_utc=False,
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
worker_send_task_events=True,
task_send_sent_event=True,
) )
# 在 Celery 中设置此参数无效 # 在 Celery 中设置此参数无效
+3 -5
View File
@@ -9,13 +9,11 @@ from sqlalchemy.orm import Session
from backend.app.task.model.result import Task, TaskExtended, TaskSet from backend.app.task.model.result import Task, TaskExtended, TaskSet
from backend.app.task.session import SessionManager from backend.app.task.session import SessionManager
"""
重写 from celery.backends.database 内部 DatabaseBackend 此类实现与模型配合不佳导致 fba 创建表和 alembic 迁移困难
"""
class DatabaseBackend(BaseBackend): class DatabaseBackend(BaseBackend):
"""The database result backend.""" """
重写 celery.backends.database DatabaseBackend此类实现与模型配合不佳导致 fba 创建表和 alembic 迁移困难
"""
# ResultSet.iterate should sleep this much between each pool, # ResultSet.iterate should sleep this much between each pool,
# to not bombard the database with queries. # to not bombard the database with queries.
+7 -8
View File
@@ -1,11 +1,10 @@
from datetime import datetime, timezone
import sqlalchemy as sa import sqlalchemy as sa
from celery import states from celery import states
from sqlalchemy.types import PickleType from sqlalchemy.types import PickleType
from backend.common.model import MappedBase from backend.common.model import MappedBase, TimeZone
from backend.utils.timezone import timezone
""" """
重写 celery.backends.database.models 内部所有模型适配 fba 创建表和 alembic 迁移 重写 celery.backends.database.models 内部所有模型适配 fba 创建表和 alembic 迁移
@@ -20,12 +19,12 @@ class Task(MappedBase):
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True) id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
task_id = sa.Column(sa.String(155), unique=True) task_id = sa.Column(sa.String(155), unique=True)
status = sa.Column(sa.String(50), default=states.PENDING) status = sa.Column(sa.String(64), default=states.PENDING)
result = sa.Column(PickleType, nullable=True) result = sa.Column(PickleType, nullable=True)
date_done = sa.Column( date_done = sa.Column(
sa.DateTime, TimeZone,
default=datetime.now(timezone.utc), default=timezone.now,
onupdate=datetime.now(timezone.utc), onupdate=timezone.now,
nullable=True, nullable=True,
) )
traceback = sa.Column(sa.Text, nullable=True) traceback = sa.Column(sa.Text, nullable=True)
@@ -87,7 +86,7 @@ class TaskSet(MappedBase):
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True) id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
taskset_id = sa.Column(sa.String(155), unique=True) taskset_id = sa.Column(sa.String(155), unique=True)
result = sa.Column(PickleType, nullable=True) result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(sa.DateTime, default=datetime.now(timezone.utc), nullable=True) date_done = sa.Column(TimeZone, default=timezone.now, nullable=True)
def __init__(self, taskset_id, result) -> None: # noqa: ANN001 def __init__(self, taskset_id, result) -> None: # noqa: ANN001
self.taskset_id = taskset_id self.taskset_id = taskset_id
+16 -33
View File
@@ -2,18 +2,13 @@ import asyncio
from datetime import datetime from datetime import datetime
from sqlalchemy import ( import sqlalchemy as sa
JSON,
Boolean, from sqlalchemy import event
String,
event,
)
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.model import Base, TimeZone, id_key from backend.common.model import Base, TimeZone, UniversalText, id_key
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -25,37 +20,25 @@ class TaskScheduler(Base):
__tablename__ = 'task_scheduler' __tablename__ = 'task_scheduler'
id: Mapped[id_key] = mapped_column(init=False) id: Mapped[id_key] = mapped_column(init=False)
name: Mapped[str] = mapped_column(String(50), unique=True, comment='任务名称') name: Mapped[str] = mapped_column(sa.String(64), unique=True, comment='任务名称')
task: Mapped[str] = mapped_column(String(255), comment='要运行的 Celery 任务') task: Mapped[str] = mapped_column(sa.String(256), comment='要运行的 Celery 任务')
args: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的位置参数') args: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的位置参数')
kwargs: Mapped[str | None] = mapped_column(JSON(), comment='任务可接收的关键字参数') kwargs: Mapped[str | None] = mapped_column(sa.JSON(), comment='任务可接收的关键字参数')
queue: Mapped[str | None] = mapped_column(String(255), comment='CELERY_TASK_QUEUES 中定义的队列') queue: Mapped[str | None] = mapped_column(sa.String(256), comment='CELERY_TASK_QUEUES 中定义的队列')
exchange: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的交换机') exchange: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的交换机')
routing_key: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的路由密钥') routing_key: Mapped[str | None] = mapped_column(sa.String(256), comment='低级别 AMQP 路由的路由密钥')
start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间') start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间') expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间')
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差') expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)') type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数') interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
interval_period: Mapped[str | None] = mapped_column(String(255), comment='任务运行之间的周期类型') interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(String(50), default='* * * * *', comment='任务运行的 Crontab 计划') crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='Crontab 表达式')
one_off: Mapped[bool] = mapped_column( one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
Boolean().with_variant(INTEGER, 'postgresql'), enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务')
default=False,
comment='是否仅运行一次',
)
enabled: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'),
default=True,
comment='是否启用任务',
)
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数') total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间') last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
)
no_changes: bool = False no_changes: bool = False
+1 -1
View File
@@ -23,7 +23,7 @@ class TaskSchedulerSchemeBase(SchemaBase):
type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)') type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)')
interval_every: int | None = Field(default=None, description='任务再次运行前的间隔周期数') interval_every: int | None = Field(default=None, description='任务再次运行前的间隔周期数')
interval_period: PeriodType | None = Field(default=None, description='任务运行之间的周期类型') interval_period: PeriodType | None = Field(default=None, description='任务运行之间的周期类型')
crontab: str = Field(default='* * * * *', description='运行的 Crontab 表达式') crontab: str = Field(default='* * * * *', description='Crontab 表达式')
one_off: bool = Field(default=False, description='是否仅运行一次') one_off: bool = Field(default=False, description='是否仅运行一次')
remark: str | None = Field(default=None, description='备注') remark: str | None = Field(default=None, description='备注')
@@ -92,7 +92,7 @@ class TaskSchedulerService:
raise errors.NotFoundError(msg='任务调度不存在') raise errors.NotFoundError(msg='任务调度不存在')
if task_scheduler.name != obj.name and await task_scheduler_dao.get_by_name(db, obj.name): if task_scheduler.name != obj.name and await task_scheduler_dao.get_by_name(db, obj.name):
raise errors.ConflictError(msg='任务调度已存在') raise errors.ConflictError(msg='任务调度已存在')
if task_scheduler.type == TaskSchedulerType.CRONTAB: if obj.type == TaskSchedulerType.CRONTAB:
crontab_verify(obj.crontab) crontab_verify(obj.crontab)
count = await task_scheduler_dao.update(db, pk, obj) count = await task_scheduler_dao.update(db, pk, obj)
return count return count
+7 -4
View File
@@ -2,17 +2,20 @@ from celery import shared_task
from backend.app.admin.service.login_log_service import login_log_service from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.opera_log_service import opera_log_service from backend.app.admin.service.opera_log_service import opera_log_service
from backend.database.db import async_db_session
@shared_task @shared_task
async def delete_db_opera_log() -> str: async def delete_db_opera_log() -> str:
"""自动删除数据库操作日志""" """自动删除数据库操作日志"""
await opera_log_service.delete_all() async with async_db_session.begin() as db:
return 'Success' await opera_log_service.delete_all(db=db)
return 'Success'
@shared_task @shared_task
async def delete_db_login_log() -> str: async def delete_db_login_log() -> str:
"""自动删除数据库登录日志""" """自动删除数据库登录日志"""
await login_log_service.delete_all() async with async_db_session.begin() as db:
return 'Success' await login_log_service.delete_all(db=db)
return 'Success'
+20 -36
View File
@@ -23,7 +23,7 @@ from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils._await import run_await from backend.utils.async_helper import run_await
from backend.utils.serializers import select_as_dict from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -56,14 +56,7 @@ class ModelEntry(ScheduleEntry):
): ):
self.schedule = schedules.schedule(timedelta(**{model.interval_period: model.interval_every})) self.schedule = schedules.schedule(timedelta(**{model.interval_period: model.interval_every}))
elif model.type == TaskSchedulerType.CRONTAB and model.crontab is not None: elif model.type == TaskSchedulerType.CRONTAB and model.crontab is not None:
crontab_split = model.crontab.split(' ') self.schedule = TzAwareCrontab.from_string(model.crontab)
self.schedule = TzAwareCrontab(
minute=crontab_split[0],
hour=crontab_split[1],
day_of_week=crontab_split[2],
day_of_month=crontab_split[3],
month_of_year=crontab_split[4],
)
else: else:
raise errors.NotFoundError(msg=f'{self.name} 计划为空!') raise errors.NotFoundError(msg=f'{self.name} 计划为空!')
# logger.debug('Schedule: {}'.format(self.schedule)) # logger.debug('Schedule: {}'.format(self.schedule))
@@ -85,12 +78,10 @@ class ModelEntry(ScheduleEntry):
continue continue
self.options[option] = value self.options[option] = value
expires = getattr(model, 'expires_', None) if model.expire_seconds is not None:
if expires: self.options['expires'] = model.expire_seconds
if isinstance(expires, int): elif model.expire_time is not None:
self.options['expires'] = expires self.options['expires'] = timezone.from_datetime(model.expire_time)
elif isinstance(expires, datetime):
self.options['expires'] = timezone.from_datetime(expires)
if not model.last_run_time: if not model.last_run_time:
model.last_run_time = timezone.now() model.last_run_time = timezone.now()
@@ -105,10 +96,15 @@ class ModelEntry(ScheduleEntry):
"""禁用任务""" """禁用任务"""
model.no_changes = True model.no_changes = True
self.model.enabled = self.enabled = model.enabled = False self.model.enabled = self.enabled = model.enabled = False
async with async_db_session.begin(): async with async_db_session.begin() as db:
model.enabled = False stmt = select(TaskScheduler).where(TaskScheduler.id == model.id)
query = await db.execute(stmt)
task = query.scalars().first()
if task:
task.no_changes = True
task.enabled = False
def is_due(self) -> tuple[bool, int | float]: def is_due(self) -> tuple[bool, int | float | datetime]:
"""任务到期状态""" """任务到期状态"""
if not self.model.enabled: if not self.model.enabled:
# 重新启用时延迟 5 秒 # 重新启用时延迟 5 秒
@@ -196,7 +192,7 @@ class ModelEntry(ScheduleEntry):
if not obj: if not obj:
obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump()) obj = TaskScheduler(**CreateTaskSchedulerParam(task=task, **spec).model_dump())
elif isinstance(schedule, schedules.crontab): elif isinstance(schedule, schedules.crontab):
crontab = f'{schedule._orig_minute} {schedule._orig_hour} {schedule._orig_day_of_week} {schedule._orig_day_of_month} {schedule._orig_month_of_year}' # noqa: E501 crontab = f'{schedule._orig_minute} {schedule._orig_hour} {schedule._orig_day_of_month} {schedule._orig_month_of_year} {schedule._orig_day_of_week}' # noqa: E501
crontab_verify(crontab) crontab_verify(crontab)
spec = { spec = {
'name': name, 'name': name,
@@ -256,7 +252,7 @@ class ModelEntry(ScheduleEntry):
'exchange': exchange, 'exchange': exchange,
'routing_key': routing_key, 'routing_key': routing_key,
'start_time': start_time, 'start_time': start_time,
'expire_time': expires, 'expire_time': None,
'expire_seconds': expire_seconds, 'expire_seconds': expire_seconds,
'one_off': one_off, 'one_off': one_off,
} }
@@ -265,6 +261,8 @@ class ModelEntry(ScheduleEntry):
data['expire_seconds'] = expires data['expire_seconds'] = expires
elif isinstance(expires, timedelta): elif isinstance(expires, timedelta):
data['expire_time'] = timezone.now() + expires data['expire_time'] = timezone.now() + expires
elif isinstance(expires, datetime):
data['expire_time'] = expires
return data return data
@@ -288,20 +286,6 @@ class DatabaseScheduler(Scheduler):
self._finalize = Finalize(self, self.sync, exitpriority=5) self._finalize = Finalize(self, self.sync, exitpriority=5)
self.max_interval = kwargs.get('max_interval') or self.app.conf.beat_max_loop_interval or DEFAULT_MAX_INTERVAL self.max_interval = kwargs.get('max_interval') or self.app.conf.beat_max_loop_interval or DEFAULT_MAX_INTERVAL
def install_default_entries(self, data) -> None: # noqa: ANN001
"""重写父函数"""
entries = {}
if self.app.conf.result_expires:
entries.setdefault(
'celery.backend_cleanup',
{
'task': 'celery.backend_cleanup',
'schedule': schedules.crontab('0', '4', '*'),
'options': {'expire_seconds': 12 * 3600},
},
)
self.update_from_dict(entries)
def schedules_equal(self, *args, **kwargs) -> bool: def schedules_equal(self, *args, **kwargs) -> bool:
"""重写父函数""" """重写父函数"""
if self._heap_invalidated: if self._heap_invalidated:
@@ -367,7 +351,7 @@ class DatabaseScheduler(Scheduler):
def update_from_dict(self, beat_dict: dict) -> None: def update_from_dict(self, beat_dict: dict) -> None:
"""重写父函数""" """重写父函数"""
s = {} s = {}
name = None
try: try:
for name, entry_fields in beat_dict.items(): for name, entry_fields in beat_dict.items():
entry = run_await(self.Entry.from_entry)(name, app=self.app, **entry_fields) entry = run_await(self.Entry.from_entry)(name, app=self.app, **entry_fields)
@@ -399,7 +383,7 @@ class DatabaseScheduler(Scheduler):
"""获取所有任务调度""" """获取所有任务调度"""
async with async_db_session() as db: async with async_db_session() as db:
logger.debug('DatabaseScheduler: Fetching database schedule') logger.debug('DatabaseScheduler: Fetching database schedule')
stmt = select(TaskScheduler).where(TaskScheduler.enabled == 1) stmt = select(TaskScheduler).where(TaskScheduler.enabled == True) # noqa: E712
query = await db.execute(stmt) query = await db.execute(stmt)
schedulers = query.scalars().all() schedulers = query.scalars().all()
s = {} s = {}
+7 -37
View File
@@ -1,7 +1,5 @@
from datetime import datetime
from celery import schedules from celery import schedules
from celery.schedules import ParseException, crontab from celery.schedules import ParseException
from backend.common.exception import errors from backend.common.exception import errors
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -21,46 +19,18 @@ class TzAwareCrontab(schedules.crontab):
app=app, app=app,
) )
def is_due(self, last_run_at: datetime) -> tuple[bool, int | float]:
"""
任务到期状态
:param last_run_at: 最后运行时间 def crontab_verify(crontab: str) -> None:
:return:
"""
rem_delta = self.remaining_estimate(last_run_at)
rem = max(rem_delta.total_seconds(), 0)
due = rem == 0
if due:
rem_delta = self.remaining_estimate(self.now())
rem = max(rem_delta.total_seconds(), 0)
return schedules.schedstate(is_due=due, next=rem)
def __reduce__(self) -> tuple[type, tuple[str, str, str, str, str], None]:
return (
self.__class__,
(
self._orig_minute,
self._orig_hour,
self._orig_day_of_week,
self._orig_day_of_month,
self._orig_month_of_year,
),
None,
)
def crontab_verify(crontab_str: str) -> None:
""" """
验证 Celery crontab 表达式 验证标准 crontab 表达式
:param crontab_str: 计划表达式 :param crontab: 标准 crontab 表达式
:return: :return:
""" """
crontab_split = crontab_str.split(' ') crontab_split = crontab.split(' ')
if len(crontab_split) != 5: if len(crontab_split) != 5:
raise errors.RequestError(msg='Crontab 表达式非法') raise errors.RequestError(msg='Crontab 表达式非法')
try: try:
crontab(*crontab_split) TzAwareCrontab.from_string(crontab)
except ParseException: except (ParseException, ValueError):
raise errors.RequestError(msg='Crontab 表达式非法') raise errors.RequestError(msg='Crontab 表达式非法')
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# work && beat
celery -A backend.app.task.celery worker -l info -P gevent -c 100 &
# beat
celery -A backend.app.task.celery beat -l info &
# flower
celery -A backend.app.task.celery flower --port=8555 --basic-auth=admin:123456
+684 -79
View File
@@ -1,60 +1,322 @@
import asyncio import asyncio
import re
import secrets
import subprocess import subprocess
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Literal from typing import Annotated, Literal
import anyio
import cappa import cappa
import granian import granian
from cappa.output import error_format from cappa.output import error_format
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import IntPrompt from rich.prompt import IntPrompt, Prompt
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from sqlalchemy import text from sqlalchemy import text
from watchfiles import PythonFilter from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.concurrency import run_in_threadpool
from watchfiles import Change, PythonFilter
from backend import __version__ from backend import __version__
from backend.common.enums import DataBaseType, PrimaryKeyType from backend.common.enums import DataBaseType, PrimaryKeyType
from backend.common.exception.errors import BaseExceptionError from backend.common.exception.errors import BaseExceptionError
from backend.common.model import MappedBase
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.core.path_conf import (
from backend.plugin.code_generator.schema.code import ImportParam BASE_PATH,
from backend.plugin.code_generator.service.business_service import gen_business_service ENV_EXAMPLE_FILE_PATH,
from backend.plugin.code_generator.service.code_service import gen_service ENV_FILE_PATH,
from backend.plugin.tools import get_plugin_sql LOCALE_DIR,
from backend.utils._await import run_await MYSQL_SCRIPT_DIR,
PLUGIN_DIR,
POSTGRESQL_SCRIPT_DIR,
RELOAD_LOCK_FILE,
)
from backend.database.db import (
async_db_session,
create_database_async_engine,
create_database_async_session,
create_database_url,
)
from backend.database.redis import RedisCli, redis_client
from backend.plugin.core import (
build_sql_filename,
get_plugin_destroy_sql,
get_plugin_sql,
get_plugins,
get_required_plugins,
)
from backend.plugin.installer import install_git_frontend_plugin, install_git_plugin, install_zip_plugin, zip_plugin
from backend.plugin.installer import remove_plugin as _remove_plugin
from backend.plugin.requirements import uninstall_requirements_async
from backend.utils.console import console from backend.utils.console import console
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script from backend.utils.dynamic_import import import_module_cached
from backend.utils.sql_parser import parse_sql_script
from backend.utils.timezone import timezone
output_help = '\n更多信息,尝试 "[cyan]--help[/]"' output_help = "\n更多信息,尝试 '[cyan]--help[/]'"
class CustomReloadFilter(PythonFilter): class CustomReloadFilter(PythonFilter):
"""自定义重载过滤器""" """自定义重载过滤器"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(extra_extensions=['.json', '.yaml', '.yml']) self.extra_extensions = ('.json', '.yaml', '.yml')
super().__init__(extra_extensions=self.extra_extensions)
def __call__(self, change: Change, path: str) -> bool:
if RELOAD_LOCK_FILE.exists():
return False
file_path = Path(path).resolve()
if file_path.suffix in self.extra_extensions and not file_path.is_relative_to(LOCALE_DIR.resolve()):
return False
return super().__call__(change, path)
def setup_env_file() -> bool:
"""交互式配置并生成 .env 环境变量文件"""
if not ENV_EXAMPLE_FILE_PATH.exists():
console.caution('.env.example 文件不存在')
return False
try:
env_content = Path(ENV_EXAMPLE_FILE_PATH).read_text(encoding='utf-8')
console.note('配置数据库连接信息...')
db_type = Prompt.ask('数据库类型', choices=['mysql', 'postgresql'], default='postgresql')
db_host = Prompt.ask('数据库主机', default='127.0.0.1')
db_port = Prompt.ask('数据库端口', default='5432' if db_type == 'postgresql' else '3306')
db_user = Prompt.ask('数据库用户名', default='postgres' if db_type == 'postgresql' else 'root')
db_password = Prompt.ask('数据库密码', password=True, default='123456')
console.note('配置 Redis 连接信息...')
redis_host = Prompt.ask('Redis 主机', default='127.0.0.1')
redis_port = Prompt.ask('Redis 端口', default='6379')
redis_password = Prompt.ask('Redis 密码(留空表示无密码)', password=True, default='')
redis_db = Prompt.ask('Redis 数据库编号', default='0')
console.info('生成 Token 密钥...')
token_secret = secrets.token_urlsafe(32)
console.info('写入 .env 文件...')
env_content = env_content.replace("DATABASE_TYPE='postgresql'", f"DATABASE_TYPE='{db_type}'")
settings.DATABASE_TYPE = db_type
env_content = env_content.replace("DATABASE_HOST='127.0.0.1'", f"DATABASE_HOST='{db_host}'")
settings.DATABASE_HOST = db_host
env_content = env_content.replace('DATABASE_PORT=5432', f'DATABASE_PORT={db_port}')
settings.DATABASE_PORT = db_port
env_content = env_content.replace("DATABASE_USER='postgres'", f"DATABASE_USER='{db_user}'")
settings.DATABASE_USER = db_user
env_content = env_content.replace("DATABASE_PASSWORD='123456'", f"DATABASE_PASSWORD='{db_password}'")
settings.DATABASE_PASSWORD = db_password
env_content = env_content.replace("REDIS_HOST='127.0.0.1'", f"REDIS_HOST='{redis_host}'")
settings.REDIS_HOST = redis_host
env_content = env_content.replace('REDIS_PORT=6379', f'REDIS_PORT={redis_port}')
settings.REDIS_PORT = redis_port
env_content = env_content.replace("REDIS_PASSWORD=''", f"REDIS_PASSWORD='{redis_password}'")
settings.REDIS_PASSWORD = redis_password
env_content = env_content.replace('REDIS_DATABASE=0', f'REDIS_DATABASE={redis_db}')
settings.REDIS_DATABASE = redis_db
env_content = re.sub(r"TOKEN_SECRET_KEY='[^']*'", f"TOKEN_SECRET_KEY='{token_secret}'", env_content)
settings.TOKEN_SECRET_KEY = token_secret
Path(ENV_FILE_PATH).write_text(env_content, encoding='utf-8')
console.tip('.env 文件创建成功')
except Exception as e:
console.caution(f'.env 文件创建失败: {e}')
return False
else:
return True
async def create_database(conn: AsyncConnection) -> bool:
"""创建或重建数据库"""
try:
terminate_sql = None
if DataBaseType.mysql == settings.DATABASE_TYPE:
check_sql = f"SHOW DATABASES LIKE '{settings.DATABASE_SCHEMA}'"
drop_sql = f'DROP DATABASE IF EXISTS `{settings.DATABASE_SCHEMA}`'
create_sql = (
f'CREATE DATABASE `{settings.DATABASE_SCHEMA}` CHARACTER SET {settings.DATABASE_CHARSET} '
f'COLLATE {settings.DATABASE_CHARSET}_unicode_ci'
)
else:
check_sql = f"SELECT 1 FROM pg_database WHERE datname = '{settings.DATABASE_SCHEMA}'"
drop_sql = f'DROP DATABASE IF EXISTS {settings.DATABASE_SCHEMA}'
create_sql = f'CREATE DATABASE {settings.DATABASE_SCHEMA}'
terminate_sql = (
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
f"WHERE datname = '{settings.DATABASE_SCHEMA}' AND pid <> pg_backend_pid()"
)
result = await conn.execute(text(check_sql))
exists = result.fetchone() is not None
console.note(f'重建 {settings.DATABASE_SCHEMA} 数据库...')
if exists:
if terminate_sql:
await conn.execute(text(terminate_sql))
await conn.execute(text(drop_sql))
await conn.execute(text(create_sql))
console.tip('数据库创建成功')
except Exception as e:
console.caution(f'数据库创建失败: {e}')
return False
else:
return True
def _build_db_config_panel_content() -> Text:
"""构建数据库配置面板内容"""
panel_content = Text()
panel_content.append('【数据库配置】', style='bold green')
panel_content.append('\n\n • 类型: ')
panel_content.append(f'{settings.DATABASE_TYPE}', style='yellow')
panel_content.append('\n • 主机:')
panel_content.append(f'{settings.DATABASE_HOST}:{settings.DATABASE_PORT}', style='yellow')
panel_content.append('\n • 数据库:')
panel_content.append(f'{settings.DATABASE_SCHEMA}', style='yellow')
panel_content.append('\n • 主键模式:')
panel_content.append(f'{settings.DATABASE_PK_MODE}', style='yellow')
return panel_content
async def auto_init() -> None:
"""自动化初始化流程"""
console.print('\n[bold cyan]步骤 1/3:[/] 配置环境变量', style='bold')
panel_content = Text()
panel_content.append('【环境变量配置】', style='bold green')
panel_content.append('\n\n • 数据库连接信息')
panel_content.append('\n • Redis 连接信息')
panel_content.append('\n • Token 密钥(自动生成)')
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 环境变量', border_style='cyan', padding=(1, 2)))
if not setup_env_file():
raise cappa.Exit('.env 文件配置失败', code=1)
console.print('\n[bold cyan]步骤 2/3:[/] 数据库创建', style='bold')
panel_content = _build_db_config_panel_content()
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 数据库', border_style='cyan', padding=(1, 2)))
ok = Prompt.ask('即将[red]新建/重建数据库[/red],确认继续吗?', choices=['y', 'n'], default='n')
if ok.lower() == 'y':
async_init_engine = create_database_async_engine(create_database_url(with_database=False))
async with async_init_engine.connect() as conn:
await conn.execution_options(isolation_level='AUTOCOMMIT')
if not await create_database(conn):
raise cappa.Exit('数据库创建失败', code=1)
else:
console.warning('已取消数据库操作')
console.print('\n[bold cyan]步骤 3/3:[/] 初始化数据库表和数据', style='bold')
async_init_engine = create_database_async_engine(create_database_url())
async_init_db_session = create_database_async_session(async_init_engine)
redis_init_client = RedisCli(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
password=settings.REDIS_PASSWORD,
db=settings.REDIS_DATABASE,
)
await redis_init_client.init()
async with async_init_db_session.begin() as db:
await init(db, redis_init_client)
async def init(db: AsyncSession, redis: RedisCli) -> None:
"""交互式初始化数据库表结构和数据"""
panel_content = _build_db_config_panel_content()
pk_details = panel_content.from_markup(
'[link=https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html](了解详情)[/]'
)
panel_content.append(pk_details)
panel_content.append('\n\n【Redis 配置】', style='bold green')
panel_content.append('\n\n • 主机:')
panel_content.append(f'{settings.REDIS_HOST}:{settings.REDIS_PORT}', style='yellow')
panel_content.append('\n • 数据库:')
panel_content.append(f'{settings.REDIS_DATABASE}', style='yellow')
plugins = get_plugins()
panel_content.append('\n\n【已安装插件】', style='bold green')
panel_content.append('\n\n')
if plugins:
panel_content.append(f'{", ".join(plugins)}', style='yellow')
else:
panel_content.append('', style='dim')
console.print(Panel(panel_content, title=f'fba (v{__version__}) - 初始化', border_style='cyan', padding=(1, 2)))
ok = Prompt.ask(
'即将[red]新建/重建数据库表[/red]并[red]执行所有数据库脚本[/red],确认继续吗?', choices=['y', 'n'], default='n'
)
if ok.lower() == 'y':
try:
console.note('清理 Redis 缓存')
for prefix in [
settings.JWT_USER_REDIS_PREFIX,
settings.TOKEN_EXTRA_INFO_REDIS_PREFIX,
settings.TOKEN_REDIS_PREFIX,
settings.TOKEN_REFRESH_REDIS_PREFIX,
]:
await redis.delete_prefix(prefix)
console.note('重建数据库表')
conn = await db.connection()
await conn.run_sync(MappedBase.metadata.drop_all)
await conn.run_sync(MappedBase.metadata.create_all)
console.note('执行 SQL 脚本')
sql_scripts = await get_sql_scripts()
for sql_script in sql_scripts:
console.note(f'正在执行:{sql_script}')
await execute_sql_scripts(db, sql_script, is_init=True)
console.tip('初始化成功')
console.print('\n快试试 [bold cyan]fba run[/bold cyan] 启动服务吧~')
except Exception as e:
raise cappa.Exit(f'初始化失败:{e}', code=1)
else:
console.warning('已取消初始化操作')
def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001 def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT001
"""启动 API 服务"""
url = f'http://{host}:{port}' url = f'http://{host}:{port}'
docs_url = url + settings.FASTAPI_DOCS_URL docs_url = url + settings.FASTAPI_DOCS_URL
redoc_url = url + settings.FASTAPI_REDOC_URL redoc_url = url + settings.FASTAPI_REDOC_URL
openapi_url = url + (settings.FASTAPI_OPENAPI_URL or '') openapi_url = url + (settings.FASTAPI_OPENAPI_URL or '')
panel_content = Text() panel_content = Text()
panel_content.append(f'当前版本: v{__version__}') panel_content.append('Python 版本:', style='bold cyan')
panel_content.append(f'\n服务地址: {url}') panel_content.append(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}', style='white')
panel_content.append('\n官方文档: https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
panel_content.append('\nAPI 请求地址: ', style='bold cyan')
panel_content.append(f'{url}{settings.FASTAPI_API_V1_PATH}', style='blue')
panel_content.append('\n\n环境模式:', style='bold green')
env_style = 'yellow' if settings.ENVIRONMENT == 'dev' else 'green'
panel_content.append(f'{settings.ENVIRONMENT.upper()}', style=env_style)
plugins = get_plugins()
panel_content.append('\n已安装插件:', style='bold green')
if plugins:
panel_content.append(f'{", ".join(plugins)}', style='yellow')
else:
panel_content.append('', style='white')
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
panel_content.append(f'\n\n📖 Swagger 文档: {docs_url}', style='yellow') panel_content.append(f'\n\n📖 Swagger 文档: {docs_url}', style='bold magenta')
panel_content.append(f'\n📚 Redoc 文档: {redoc_url}', style='blue') panel_content.append(f'\n📚 Redoc 文档: {redoc_url}', style='bold magenta')
panel_content.append(f'\n📡 OpenAPI JSON: {openapi_url}', style='green') panel_content.append(f'\n📡 OpenAPI JSON: {openapi_url}', style='bold magenta')
console.print(Panel(panel_content, title='fba 服务信息', border_style='purple', padding=(1, 2))) panel_content.append('\n🌐 架构官方文档: ', style='bold magenta')
panel_content.append('https://fastapi-practices.github.io/fastapi_best_architecture_docs/')
console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2)))
granian.Granian( granian.Granian(
target='backend.main:app', target='backend.main:app',
interface='asgi', interface='asgi',
@@ -67,6 +329,7 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # noqa: FBT0
def run_celery_worker(log_level: Literal['info', 'debug']) -> None: def run_celery_worker(log_level: Literal['info', 'debug']) -> None:
"""启动 Celery worker 服务"""
try: try:
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'worker', '-l', f'{log_level}', '-P', 'gevent']) subprocess.run(['celery', '-A', 'backend.app.task.celery', 'worker', '-l', f'{log_level}', '-P', 'gevent'])
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -74,6 +337,7 @@ def run_celery_worker(log_level: Literal['info', 'debug']) -> None:
def run_celery_beat(log_level: Literal['info', 'debug']) -> None: def run_celery_beat(log_level: Literal['info', 'debug']) -> None:
"""启动 Celery beat 定时任务服务"""
try: try:
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'beat', '-l', f'{log_level}']) subprocess.run(['celery', '-A', 'backend.app.task.celery', 'beat', '-l', f'{log_level}'])
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -81,6 +345,7 @@ def run_celery_beat(log_level: Literal['info', 'debug']) -> None:
def run_celery_flower(port: int, basic_auth: str) -> None: def run_celery_flower(port: int, basic_auth: str) -> None:
"""启动 Celery flower 监控服务"""
try: try:
subprocess.run([ subprocess.run([
'celery', 'celery',
@@ -94,48 +359,170 @@ def run_celery_flower(port: int, basic_auth: str) -> None:
pass pass
async def install_plugin( async def install_plugin( # noqa: C901
path: str, path: str | None,
repo_url: str, repo_url: str | None,
frontend: bool, # noqa: FBT001
no_sql: bool, # noqa: FBT001 no_sql: bool, # noqa: FBT001
db_type: DataBaseType, db_type: DataBaseType,
pk_type: PrimaryKeyType, pk_type: PrimaryKeyType,
) -> None: ) -> None:
if not path and not repo_url: """安装插件"""
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1) if settings.ENVIRONMENT != 'dev':
if path and repo_url: raise cappa.Exit('插件安装仅在开发环境可用', code=1)
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
plugin_name = None plugin_name = None
console.print(Text('开始安装插件...', style='bold cyan')) console.note('开始安装插件...')
try: try:
if frontend:
if repo_url is None:
raise cappa.Exit('前端插件仅允许通过 Git 仓库地址安装', code=1)
frontend_project_root = Prompt.ask('请输入前端项目根路径')
plugin_name = await install_git_frontend_plugin(repo_url, frontend_project_root)
console.tip(f'前端插件 {plugin_name} 安装成功')
return
if path is None and repo_url is None:
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1)
if path and repo_url:
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
if path: if path:
plugin_name = await install_zip_plugin(file=path) plugin_name = await install_zip_plugin(file=path)
if repo_url: if repo_url:
plugin_name = await install_git_plugin(repo_url=repo_url) plugin_name = await install_git_plugin(repo_url=repo_url)
console.print(Text(f'插件 {plugin_name} 安装成功', style='bold green')) console.tip(f'插件 {plugin_name} 安装成功')
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type) console.note(f'正在同步插件 {plugin_name} 数据库表...')
if sql_file and not no_sql: try:
console.print(Text('开始自动执行插件 SQL 脚本...', style='bold cyan')) import_module_cached(f'backend.plugin.{plugin_name}.model')
await execute_sql_scripts(sql_file) except ModuleNotFoundError:
pass
else:
async with async_db_session.begin() as db:
conn = await db.connection()
await conn.run_sync(MappedBase.metadata.create_all)
if not no_sql:
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
if sql_file:
console.info(f'正在执行插件 {plugin_name} 初始化 SQL 脚本:{sql_file}')
async with async_db_session.begin() as db:
await execute_sql_scripts(db, sql_file)
else:
console.warning(f'插件 {plugin_name} 未提供初始化 SQL 脚本,跳过数据库初始化')
except Exception as e: except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1) raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
async def execute_sql_scripts(sql_scripts: str) -> None: async def remove_plugin(plugin: str | None, *, no_sql: bool = False) -> None: # noqa: C901
async with async_db_session.begin() as db: """卸载插件"""
try: if settings.ENVIRONMENT != 'dev':
stmts = await parse_sql_script(sql_scripts) raise cappa.Exit('插件卸载仅在开发环境可用', code=1)
for stmt in stmts:
await db.execute(text(stmt))
except Exception as e:
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
console.print(Text('SQL 脚本已执行完成', style='bold green')) async def remove() -> None:
plugin_dir = PLUGIN_DIR / plugin
if not plugin_dir.exists():
raise cappa.Exit(f'插件 {plugin} 不存在', code=1)
if not no_sql:
destroy_sql_file = await get_plugin_destroy_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
if destroy_sql_file:
console.note(f'正在执行插件 {plugin} 销毁 SQL 脚本:{destroy_sql_file}')
async with async_db_session.begin() as db:
await execute_destroy_sql_scripts(db, destroy_sql_file)
else:
console.warning(f'插件 {plugin} 未提供销毁 SQL 脚本,跳过数据库清理')
console.note(f'正在卸载插件 {plugin} 依赖...')
await uninstall_requirements_async(plugin)
console.note(f'正在备份插件 {plugin}...')
backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip'
await run_in_threadpool(zip_plugin, plugin_dir, backup_file)
await run_in_threadpool(_remove_plugin, plugin_dir)
console.note(f'备份文件:{backup_file}')
console.tip(f'插件 {plugin} 卸载成功')
console.print()
console.warning('请根据插件说明(README.md)移除相关配置并重启服务')
plugins = get_plugins()
if not plugins:
raise cappa.Exit('当前没有已安装的插件', code=1)
if not plugin:
table = Table(show_header=True, header_style='bold magenta')
table.add_column('编号', style='cyan', no_wrap=True, justify='center')
table.add_column('插件名称', style='green', no_wrap=True)
for idx, name in enumerate(plugins, 1):
table.add_row(str(idx), name)
console.print(table)
choice = IntPrompt.ask('请选择要卸载的插件编号', choices=[str(i) for i in range(1, len(plugins) + 1)])
plugin = plugins[choice - 1]
else:
if plugin not in plugins:
raise cappa.Exit(f'插件 {plugin} 不存在', code=1)
if plugin in get_required_plugins():
raise cappa.Exit(f'插件 {plugin} 为必需插件,禁止卸载', code=1)
try:
await remove()
except Exception as e:
raise cappa.Exit(f'插件卸载失败:{e}', code=1)
async def get_sql_scripts() -> list[str]:
"""获取所有待执行的 SQL 脚本路径列表"""
sql_scripts: list[str] = []
db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR
main_sql_file = db_script_dir / build_sql_filename(
'init',
settings.DATABASE_PK_MODE,
suffix='test_data',
)
if await anyio.Path(main_sql_file).exists():
sql_scripts.append(str(main_sql_file))
for plugin in get_plugins():
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
if plugin_sql:
sql_scripts.append(plugin_sql)
return sql_scripts
async def execute_sql_scripts(db: AsyncSession, sql_scripts: str, *, is_init: bool = False) -> None:
"""解析并执行 SQL 脚本"""
try:
stmts = await parse_sql_script(sql_scripts)
for stmt in stmts:
await db.execute(text(stmt))
except Exception as e:
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
if not is_init:
console.tip('SQL 脚本已执行完成')
async def execute_destroy_sql_scripts(db: AsyncSession, sql_scripts: str) -> None:
"""执行插件销毁 SQL 脚本"""
try:
stmts = await parse_sql_script(sql_scripts, is_destroy=True)
for stmt in stmts:
await db.execute(text(stmt))
except Exception as e:
raise cappa.Exit(f'销毁 SQL 脚本执行失败:{e}', code=1)
console.tip('销毁 SQL 脚本已执行完成')
async def import_table( async def import_table(
@@ -143,17 +530,41 @@ async def import_table(
table_schema: str, table_schema: str,
table_name: str, table_name: str,
) -> None: ) -> None:
"""导入代码生成业务和模型列"""
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try:
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try: try:
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name) obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
await gen_service.import_business_and_model(obj=obj) async with async_db_session.begin() as db:
await gen_service.import_business_and_model(db=db, obj=obj)
console.tip('代码生成业务和模型列导入成功')
console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~')
except Exception as e: except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1) raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
def generate() -> None: async def generate(*, preview: bool = False) -> None:
"""交互式代码生成"""
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try:
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try: try:
ids = [] ids = []
results = run_await(gen_business_service.get_all)() async with async_db_session() as db:
results = await gen_business_service.get_all(db=db)
if not results: if not results:
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]') raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
@@ -174,14 +585,66 @@ def generate() -> None:
) )
console.print(table) console.print(table)
business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(_id) for _id in ids]) business = IntPrompt.ask('请从中选择一个业务编号', choices=[str(id_) for id_ in ids])
# 预览
async with async_db_session() as db:
preview_data = await gen_service.preview(db=db, pk=business)
console.print('\n[bold yellow]将要生成以下文件:[/]')
file_table = Table(show_header=True, header_style='bold cyan')
file_table.add_column('文件路径', style='white')
file_table.add_column('大小', style='green', justify='right')
for filepath, content in sorted(preview_data.items()):
size = len(content)
size_str = f'{size} B' if size < 1024 else f'{size / 1024:.1f} KB'
file_table.add_row(filepath, size_str)
console.print(file_table)
if preview:
console.print('\n[bold cyan]预览模式:未执行实际生成操作[/]')
return
# 生成
console.print('\n[bold red]警告:代码生成将进行磁盘文件(覆盖)写入,切勿在生产环境中使用!!![/]')
ok = Prompt.ask('\n确认继续生成代码吗?', choices=['y', 'n'], default='n')
if ok.lower() == 'y':
async with async_db_session.begin() as db:
gen_path = await gen_service.generate(db=db, pk=business)
console.print()
console.tip('代码已生成完成')
console.print(Text('\n详情请查看:'), Text(str(gen_path), style='bold white'))
gen_path = run_await(gen_service.generate)(pk=business)
except Exception as e: except Exception as e:
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1) raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1)
console.print(Text('\n代码已生成完毕', style='bold green'))
console.print(Text('\n详情请查看:'), Text(gen_path, style='bold magenta')) def run_alembic(*args: str) -> None:
"""执行 alembic 命令"""
try:
subprocess.run(['alembic', *args], cwd=BASE_PATH.parent, check=True)
except subprocess.CalledProcessError as e:
raise cappa.Exit('Alembic 命令执行失败', code=e.returncode)
@cappa.command(help='初始化 fba 项目', default_long=True)
@dataclass
class Init:
auto: Annotated[
bool,
cappa.Arg(default=False, help='自动化初始化模式:自动创建 .env、安装依赖、创建数据库并初始化表结构'),
]
async def __call__(self) -> None:
if self.auto:
await auto_init()
else:
async with async_db_session.begin() as db:
await init(db, redis_client)
@cappa.command(help='运行 API 服务', default_long=True) @cappa.command(help='运行 API 服务', default_long=True)
@@ -212,6 +675,66 @@ class Run:
run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers) run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers)
@cappa.command(help='新增插件', default_long=True)
@dataclass
class Add:
path: Annotated[
str | None,
cappa.Arg(default=None, help='ZIP 插件的本地完整路径'),
]
repo_url: Annotated[
str | None,
cappa.Arg(default=None, help='Git 插件的仓库地址'),
]
frontend: Annotated[
bool,
cappa.Arg(short='-f', default=False, help='安装前端插件'),
]
no_sql: Annotated[
bool,
cappa.Arg(default=False, help='禁用插件 SQL 脚本自动执行'),
]
db_type: Annotated[
DataBaseType,
cappa.Arg(default=settings.DATABASE_TYPE, help='执行插件 SQL 脚本的数据库类型'),
]
pk_type: Annotated[
PrimaryKeyType,
cappa.Arg(default=settings.DATABASE_PK_MODE, help='执行插件 SQL 脚本数据库主键类型'),
]
async def __call__(self) -> None:
await install_plugin(self.path, self.repo_url, self.frontend, self.no_sql, self.db_type, self.pk_type)
@cappa.command(help='移除插件')
@dataclass
class Remove:
plugin: Annotated[
str | None,
cappa.Arg(default=None, help='要移除的插件名称'),
]
no_sql: Annotated[
bool,
cappa.Arg(default=False, help='禁用插件销毁 SQL 脚本自动执行'),
]
async def __call__(self) -> None:
await remove_plugin(self.plugin, no_sql=self.no_sql)
@cappa.command(help='格式化代码')
@dataclass
class Format:
def __call__(self) -> None:
try:
subprocess.run(['prek', 'run', '--all-files'], cwd=BASE_PATH.parent, check=False)
except FileNotFoundError:
raise cappa.Exit('prek 未安装,请先安装项目依赖', code=1)
except KeyboardInterrupt:
pass
@cappa.command(help='从当前主机启动 Celery worker 服务', default_long=True) @cappa.command(help='从当前主机启动 Celery worker 服务', default_long=True)
@dataclass @dataclass
class Worker: class Worker:
@@ -258,34 +781,6 @@ class Celery:
subcmd: cappa.Subcommands[Worker | Beat | Flower] subcmd: cappa.Subcommands[Worker | Beat | Flower]
@cappa.command(help='新增插件', default_long=True)
@dataclass
class Add:
path: Annotated[
str | None,
cappa.Arg(help='ZIP 插件的本地完整路径'),
]
repo_url: Annotated[
str | None,
cappa.Arg(help='Git 插件的仓库地址'),
]
no_sql: Annotated[
bool,
cappa.Arg(default=False, help='禁用插件 SQL 脚本自动执行'),
]
db_type: Annotated[
DataBaseType,
cappa.Arg(default='mysql', help='执行插件 SQL 脚本的数据库类型'),
]
pk_type: Annotated[
PrimaryKeyType,
cappa.Arg(default='autoincrement', help='执行插件 SQL 脚本数据库主键类型'),
]
async def __call__(self) -> None:
await install_plugin(self.path, self.repo_url, self.no_sql, self.db_type, self.pk_type)
@cappa.command(help='导入代码生成业务和模型列', default_long=True) @cappa.command(help='导入代码生成业务和模型列', default_long=True)
@dataclass @dataclass
class Import: class Import:
@@ -308,11 +803,120 @@ class Import:
@cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True) @cappa.command(name='codegen', help='代码生成(体验完整功能,请自行部署 fba vben 前端工程)', default_long=True)
@dataclass @dataclass
class CodeGenerate: class CodeGenerator:
preview: Annotated[
bool,
cappa.Arg(short='-p', default=False, help='仅预览将要生成的文件,不执行实际生成操作'),
]
subcmd: cappa.Subcommands[Import | None] = None subcmd: cappa.Subcommands[Import | None] = None
async def __call__(self) -> None:
await generate(preview=self.preview)
@cappa.command(help='生成数据库迁移文件', default_long=True)
@dataclass
class Revision:
autogenerate: Annotated[
bool,
cappa.Arg(default=True, help='自动检测模型变更并生成迁移脚本'),
]
message: Annotated[
str,
cappa.Arg(short='-m', default='', help='迁移文件的描述信息'),
]
def __call__(self) -> None: def __call__(self) -> None:
generate() args = ['revision']
if self.autogenerate:
args.append('--autogenerate')
if self.message:
args.extend(['-m', self.message])
run_alembic(*args)
console.tip('迁移文件生成成功')
@cappa.command(help='升级数据库到指定版本', default_long=True)
@dataclass
class Upgrade:
revision: Annotated[
str,
cappa.Arg(default='head', help='目标版本,默认为最新版本'),
]
def __call__(self) -> None:
run_alembic('upgrade', self.revision)
console.tip(f'数据库已升级到: {self.revision}')
@cappa.command(help='降级数据库到指定版本', default_long=True)
@dataclass
class Downgrade:
revision: Annotated[
str,
cappa.Arg(default='-1', help='目标版本,默认回退一个版本'),
]
def __call__(self) -> None:
run_alembic('downgrade', self.revision)
console.tip(f'数据库已降级到: {self.revision}')
@cappa.command(help='显示数据库当前迁移版本')
@dataclass
class Current:
verbose: Annotated[
bool,
cappa.Arg(short='-v', default=False, help='显示详细信息'),
]
def __call__(self) -> None:
args = ['current']
if self.verbose:
args.append('-v')
run_alembic(*args)
@cappa.command(help='显示迁移历史记录', default_long=True)
@dataclass
class History:
verbose: Annotated[
bool,
cappa.Arg(short='-v', default=False, help='显示详细信息'),
]
range: Annotated[
str,
cappa.Arg(short='-r', default='', help='显示指定范围的历史,例如 -r base:head'),
]
def __call__(self) -> None:
args = ['history']
if self.verbose:
args.append('-v')
if self.range:
args.extend(['-r', self.range])
run_alembic(*args)
@cappa.command(help='显示所有头版本')
@dataclass
class Heads:
verbose: Annotated[
bool,
cappa.Arg(short='-v', default=False, help='显示详细信息'),
]
def __call__(self) -> None:
args = ['heads']
if self.verbose:
args.append('-v')
run_alembic(*args)
@cappa.command(help='数据库迁移管理')
@dataclass
class Alembic:
subcmd: cappa.Subcommands[Revision | Upgrade | Downgrade | Current | History | Heads]
@cappa.command(help='一个高效的 fba 命令行界面', default_long=True) @cappa.command(help='一个高效的 fba 命令行界面', default_long=True)
@@ -322,11 +926,12 @@ class FbaCli:
str, str,
cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'), cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'),
] ]
subcmd: cappa.Subcommands[Run | Celery | Add | CodeGenerate | None] = None subcmd: cappa.Subcommands[Init | Run | Add | Remove | Format | Celery | CodeGenerator | Alembic | None] = None
async def __call__(self) -> None: async def __call__(self) -> None:
if self.sql: if self.sql:
await execute_sql_scripts(self.sql) async with async_db_session.begin() as db:
await execute_sql_scripts(db, self.sql)
def main() -> None: def main() -> None:
View File
+235
View File
@@ -0,0 +1,235 @@
import functools
from collections.abc import Callable, Sequence
from typing import Any, ParamSpec, TypeVar
from msgspec import json
from backend.common.cache.local import local_cache_manager
from backend.common.cache.pubsub import cache_pubsub_manager
from backend.common.context import ctx
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.serializers import select_columns_serialize, select_list_serialize
P = ParamSpec('P')
T = TypeVar('T')
def _build_cache_key(
name: str,
key: str | None,
key_builder: Callable[..., str] | None,
*args: Any,
**kwargs: Any,
) -> str:
"""构建缓存 Key"""
if key:
if '.' in key:
param, field = key.split('.', 1)
value = kwargs.get(param)
if value is None:
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{param}" 不存在或值为空')
if isinstance(value, list):
raise errors.ServerError(msg='缓存键构建失败:不支持从列表中提取字段,请使用 key_builder 处理列表参数')
if hasattr(value, field):
value = getattr(value, field)
elif isinstance(value, dict) and field in value:
value = value[field]
else:
raise errors.ServerError(msg=f'缓存键构建失败,对象中不存在字段 "{field}"')
else:
value = kwargs.get(key)
if value is None:
raise errors.ServerError(msg=f'缓存键构建失败,参数 "{key}" 不存在或值为空')
return f'{name}:{value}'
if key_builder:
return f'{name}:{key_builder(*args, **kwargs)}'
return name
def _serialize_result(result: Any) -> bytes:
"""
序列化缓存结果
:param result: 需要进行序列化的结果
:return:
"""
# SQLAlchemy 查询表
if hasattr(result, '__table__'):
return json.encode(select_columns_serialize(result))
# SQLAlchemy 查询列表
if (
isinstance(result, Sequence)
and not isinstance(result, (str, bytes))
and len(result) > 0
and hasattr(result[0], '__table__')
):
return json.encode(select_list_serialize(result))
# 基本类型
return json.encode(result)
def _deserialize_result(value: bytes) -> Any:
"""
反序列化缓存结果
:param value: 缓存结果
:return:
"""
try:
return json.decode(value)
except Exception:
return value
def user_key_builder() -> str:
"""基于当前用户 ID 生成缓存 Key"""
user_id = ctx.user_id
if user_id is None:
raise errors.ServerError(msg='用户缓存键构建失败')
return str(user_id)
def cached( # noqa: C901
name: str,
*,
key: str | None = None,
key_builder: Callable[..., str] | None = None,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""
缓存装饰器
:param name: 缓存名称通常为缓存 Key 前缀
:param key: 从方法参数中获取指定参数名的值作为缓存 Key key_builder 互斥
:param key_builder: 自定义 Key 生成函数 key 互斥
:return:
"""
if key is not None and key_builder is not None:
raise errors.ServerError(msg='缓存 key 和 key_builder 不能同时使用')
def decorator(func: Callable[P, T]) -> Callable[P, T]: # noqa: C901
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
cache_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
# L1: 本地缓存
if settings.CACHE_LOCAL_ENABLED:
local_value = local_cache_manager.get(cache_key)
if local_value is not None:
return local_value
# L2: Redis 缓存
try:
redis_value = await redis_client.get(cache_key)
if redis_value is not None:
result = _deserialize_result(redis_value)
# 回填 L1
if settings.CACHE_LOCAL_ENABLED:
local_cache_manager.set(cache_key, result)
return result
except Exception as e:
log.warning(f'[Cache] GET error: {e}')
# 缓存未命中
result = await func(*args, **kwargs)
if result is not None:
try:
serialized_result = _serialize_result(result)
deserialized_result = _deserialize_result(serialized_result)
# 回填 L1
if settings.CACHE_LOCAL_ENABLED:
local_cache_manager.set(cache_key, deserialized_result)
# 回填 L2
if settings.CACHE_REDIS_TTL:
await redis_client.setex(cache_key, settings.CACHE_REDIS_TTL, serialized_result)
else:
await redis_client.set(cache_key, serialized_result)
except Exception as e:
log.warning(f'[Cache] SET error: {e}')
return result
return wrapper
return decorator
def cache_invalidate( # noqa: C901
name: str,
*,
key: str | None = None,
key_builder: Callable[..., str] | None = None,
atomic: bool = True,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""
缓存失效装饰器
:param name: 缓存名称通常为缓存 Key 前缀
:param key: 从方法参数中获取指定参数名的值作为缓存 Key key_builder 互斥
:param key_builder: 自定义 Key 生成函数 key 互斥
:param atomic: 是否保证缓存原子性
:return:
"""
if key is not None and key_builder is not None:
raise errors.ServerError(msg='缓存 key 和 key_builder 不能同时使用')
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
result = await func(*args, **kwargs)
# 尝试失效缓存
invalidate_success = False
invalidate_error = None
try:
invalidate_key = _build_cache_key(name, key, key_builder, *args, **kwargs)
# L1 缓存失效
if settings.CACHE_LOCAL_ENABLED:
if invalidate_key == name:
local_cache_manager.delete_prefix(invalidate_key)
else:
local_cache_manager.delete(invalidate_key)
# 广播失效消息(通知其他节点清除本地缓存)
if settings.CACHE_LOCAL_ENABLED:
if invalidate_key == name:
await cache_pubsub_manager.publish_invalidation(invalidate_key, is_delete_prefix=True)
else:
await cache_pubsub_manager.publish_invalidation(invalidate_key)
# L2 缓存失效
if invalidate_key == name:
await redis_client.delete_prefix(invalidate_key)
else:
await redis_client.delete(invalidate_key)
except Exception as e:
log.error(f'[Cache] INVALIDATE error: {e}')
invalidate_error = e
else:
invalidate_success = True
# 原子性检查
if atomic and not invalidate_success:
raise errors.ServerError(msg='缓存失效失败,数据可能不一致', data=invalidate_error)
return result
return wrapper
return decorator
+56
View File
@@ -0,0 +1,56 @@
from typing import Any
import cachebox
from backend.core.conf import settings
class LocalCacheManager:
"""本地缓存管理器"""
def __init__(self) -> None:
self.hot_cache: cachebox.TTLCache = cachebox.TTLCache(
settings.CACHE_LOCAL_MAXSIZE, ttl=settings.CACHE_LOCAL_TTL
)
def get(self, key: str) -> Any:
"""获取缓存"""
try:
return self.hot_cache[key]
except KeyError:
return None
def set(self, key: str, value: Any) -> None:
"""设置缓存"""
self.hot_cache[key] = value
def delete(self, key: str) -> bool:
"""删除缓存"""
try:
del self.hot_cache[key]
except KeyError:
return False
return True
def clear(self) -> None:
"""清空缓存"""
self.hot_cache.clear()
def delete_prefix(self, prefix: str, exclude: str | list[str] | None = None) -> None:
"""
删除指定前缀的缓存
:param prefix: 要删除的键前缀
:param exclude: 要排除的键或键列表
:return:
"""
exclude_set = set(exclude) if isinstance(exclude, list) else {exclude} if isinstance(exclude, str) else set()
for key in list(self.hot_cache.keys()):
if key.startswith(prefix) and key not in exclude_set:
try:
del self.hot_cache[key]
except KeyError:
pass
local_cache_manager = LocalCacheManager()
+112
View File
@@ -0,0 +1,112 @@
import asyncio
import json
from backend.common.cache.local import local_cache_manager
from backend.common.log import log
from backend.core.conf import settings
from backend.database.redis import RedisCli, redis_client
class CachePubSubManager:
"""缓存 Pub/Sub 管理器"""
_pubsub_task: asyncio.Task | None = None
@staticmethod
async def publish_invalidation(key: str, *, is_delete_prefix: bool) -> None:
"""
发布缓存失效通知
:param key: 缓存键
:param is_delete_prefix: 是否删除符合前缀的所有缓存
:return:
"""
try:
message = json.dumps({'key': key, 'is_delete_prefix': is_delete_prefix})
await redis_client.publish(settings.CACHE_PUBSUB_CHANNEL, message)
except Exception as e:
log.warning(f'[CachePubSub] 发布通知失败: {e}')
@staticmethod
async def subscribe_and_listen() -> None: # noqa: C901
"""订阅并监听缓存失效通知"""
reconnect_attempts = 0
while reconnect_attempts < settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
pubsub_client: RedisCli | None = None
pubsub = None
try:
# 使用独立连接
pubsub_client = RedisCli()
pubsub = pubsub_client.pubsub()
await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL)
# 发布订阅成功
reconnect_attempts = 0
async for message in pubsub.listen():
if message['type'] == 'message':
try:
data = json.loads(message['data'])
key = data['key']
if not data['is_delete_prefix']:
local_cache_manager.delete(key)
else:
local_cache_manager.delete_prefix(key)
except json.JSONDecodeError as e:
log.warning(f'[CachePubSub] 消息格式错误 {e}')
except Exception as e:
log.error(f'[CachePubSub] 处理通知失败: {e}')
except asyncio.CancelledError:
break
except Exception as e:
reconnect_attempts += 1
log.error(
f'[CachePubSub] 订阅异常 ({reconnect_attempts}/{settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS}): {e}'
)
if reconnect_attempts >= settings.CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS:
log.error('[CachePubSub] 达到最大重连次数,停止订阅')
break
await asyncio.sleep(settings.CACHE_PUBSUB_RECONNECT_DELAY)
finally:
if pubsub_client:
try:
await pubsub_client.aclose()
except Exception:
pass
if pubsub:
try:
await pubsub.aclose()
except Exception:
pass
@classmethod
def start_listener(cls) -> None:
"""启动缓存 Pub/Sub 监听器"""
if not settings.CACHE_LOCAL_ENABLED:
return
if cls._pubsub_task is None or cls._pubsub_task.done():
cls._pubsub_task = asyncio.create_task(cls.subscribe_and_listen())
@classmethod
async def stop_listener(cls) -> None:
"""停止缓存 Pub/Sub 监听器"""
if cls._pubsub_task is None:
return
if not cls._pubsub_task.done():
cls._pubsub_task.cancel()
try:
await cls._pubsub_task
except asyncio.CancelledError:
pass
cls._pubsub_task = None
cache_pubsub_manager = CachePubSubManager()
+4 -1
View File
@@ -13,12 +13,15 @@ class TypedContextProtocol(Protocol):
region: str | None region: str | None
city: str | None city: str | None
user_agent: str user_agent: str | None
os: str | None os: str | None
browser: str | None browser: str | None
device: str | None device: str | None
permission: str | None permission: str | None
language: str
user_id: int | None
class TypedContext(TypedContextProtocol, _Context): class TypedContext(TypedContextProtocol, _Context):
+14 -4
View File
@@ -1,6 +1,7 @@
import dataclasses import dataclasses
from datetime import datetime from datetime import datetime
from typing import Any
from fastapi import Response from fastapi import Response
@@ -17,7 +18,7 @@ class IpInfo:
@dataclasses.dataclass @dataclasses.dataclass
class UserAgentInfo: class UserAgentInfo:
user_agent: str user_agent: str | None
os: str | None os: str | None
browser: str | None browser: str | None
device: str | None device: str | None
@@ -56,7 +57,7 @@ class NewToken:
@dataclasses.dataclass @dataclasses.dataclass
class TokenPayload: class TokenPayload:
id: int user_id: int
session_uuid: str session_uuid: str
expire_time: datetime expire_time: datetime
@@ -70,6 +71,15 @@ class UploadUrl:
class SnowflakeInfo: class SnowflakeInfo:
timestamp: int timestamp: int
datetime: str datetime: str
cluster_id: int datacenter_id: int
node_id: int worker_id: int
sequence: int sequence: int
@dataclasses.dataclass(slots=True)
class PluginEntry:
name: str
depends_on: list[str] | None = None
extend: str | None = None
routers: list[str] | None = None
api: dict[str, Any] | None = None
+16 -9
View File
@@ -93,7 +93,7 @@ class OperaLogCipherType(IntEnum):
aes = 0 aes = 0
md5 = 1 md5 = 1
itsdangerous = 2 itsdangerous = 2
plan = 3 plain = 3
class StatusType(IntEnum): class StatusType(IntEnum):
@@ -103,14 +103,6 @@ class StatusType(IntEnum):
enable = 1 enable = 1
class UserSocialType(StrEnum):
"""用户社交类型"""
github = 'GitHub'
google = 'Google'
linux_do = 'LinuxDo'
class FileType(StrEnum): class FileType(StrEnum):
"""文件类型""" """文件类型"""
@@ -118,6 +110,13 @@ class FileType(StrEnum):
video = 'video' video = 'video'
class PluginLevelType(StrEnum):
"""插件级别类型"""
app = 'app'
extend = 'extend'
class PluginType(StrEnum): class PluginType(StrEnum):
"""插件类型""" """插件类型"""
@@ -146,3 +145,11 @@ class PrimaryKeyType(StrEnum):
autoincrement = 'autoincrement' autoincrement = 'autoincrement'
snowflake = 'snowflake' snowflake = 'snowflake'
class LifespanStage(IntEnum):
"""lifespan 执行阶段"""
core = 0
plugin = 1
tail = 2
+1
View File
@@ -16,6 +16,7 @@ class BaseExceptionError(Exception):
self.data = data self.data = data
# The original background task: https://www.starlette.io/background/ # The original background task: https://www.starlette.io/background/
self.background = background self.background = background
super().__init__(msg)
class HTTPError(HTTPException): class HTTPError(HTTPException):
+60 -9
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError from pydantic import ValidationError
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
from backend.common.context import ctx from backend.common.context import ctx
@@ -46,16 +47,14 @@ async def _validation_exception_handler(exc: RequestValidationError | Validation
if i18n.current_language != 'en-US': if i18n.current_language != 'en-US':
custom_message = t(f'pydantic.{error["type"]}') custom_message = t(f'pydantic.{error["type"]}')
if custom_message: if custom_message:
ctx = error.get('ctx') error_ctx = error.get('ctx')
if not ctx: if not error_ctx:
error['msg'] = custom_message error['msg'] = custom_message
else: else:
ctx_error = ctx.get('error') e = error_ctx.get('error')
if ctx_error: if e:
error['msg'] = custom_message.format(**ctx) error['msg'] = custom_message.format(**error_ctx)
error['ctx']['error'] = ( error['ctx']['error'] = e.__str__().replace("'", '"') if isinstance(e, Exception) else None
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
)
errors.append(error) errors.append(error)
error = errors[0] error = errors[0]
if error.get('type') == 'json_invalid': if error.get('type') == 'json_invalid':
@@ -77,7 +76,7 @@ async def _validation_exception_handler(exc: RequestValidationError | Validation
return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content) return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_422, content=content)
def register_exception(app: FastAPI) -> None: def register_exception(app: FastAPI) -> None: # noqa: C901
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
""" """
@@ -196,3 +195,55 @@ def register_exception(app: FastAPI) -> None:
status_code=StandardResponseCode.HTTP_500, status_code=StandardResponseCode.HTTP_500,
content=content, content=content,
) )
if settings.MIDDLEWARE_CORS:
@app.exception_handler(StandardResponseCode.HTTP_500)
async def cors_custom_code_500_exception_handler(request: Request, exc: BaseExceptionError | Exception):
"""
跨域自定义 500 异常处理
:param request: FastAPI 请求对象
:param exc: 自定义异常
:return:
"""
if isinstance(exc, BaseExceptionError):
content = {
'code': exc.code,
'msg': exc.msg,
'data': exc.data,
}
else:
if settings.ENVIRONMENT == 'dev':
content = {
'code': StandardResponseCode.HTTP_500,
'msg': str(exc),
'data': None,
}
else:
res = response_base.fail(res=CustomResponseCode.HTTP_500)
content = res.model_dump()
content.update(trace_id=get_request_trace_id())
response = MsgSpecJSONResponse(
status_code=exc.code if isinstance(exc, BaseExceptionError) else StandardResponseCode.HTTP_500,
content=content,
background=exc.background if isinstance(exc, BaseExceptionError) else None,
)
origin = request.headers.get('origin')
if origin:
cors = CORSMiddleware(
app=app,
allow_origins=settings.CORS_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
expose_headers=settings.CORS_EXPOSE_HEADERS,
)
response.headers.update(cors.simple_headers)
has_cookie = 'cookie' in request.headers
if cors.allow_all_origins and has_cookie:
response.headers['Access-Control-Allow-Origin'] = origin
elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers.add_vary_header('Origin')
return response
+20 -3
View File
@@ -6,6 +6,9 @@ from typing import Any
import yaml import yaml
from starlette_context.errors import ContextDoesNotExistError
from backend.common.context import ctx
from backend.core.conf import settings from backend.core.conf import settings
from backend.core.path_conf import LOCALE_DIR from backend.core.path_conf import LOCALE_DIR
@@ -15,7 +18,20 @@ class I18n:
def __init__(self) -> None: def __init__(self) -> None:
self.locales: dict[str, dict[str, Any]] = {} self.locales: dict[str, dict[str, Any]] = {}
self.current_language: str = settings.I18N_DEFAULT_LANGUAGE self.load_locales()
@property
def current_language(self) -> str:
"""获取当前请求的语言"""
try:
return ctx.language
except (AttributeError, LookupError, ContextDoesNotExistError):
return settings.I18N_DEFAULT_LANGUAGE
@current_language.setter
def current_language(self, language: str) -> None:
"""设置当前请求的语言"""
ctx.language = language
def load_locales(self) -> None: def load_locales(self) -> None:
"""加载语言文本""" """加载语言文本"""
@@ -38,7 +54,7 @@ class I18n:
case 'json': case 'json':
self.locales[lang] = json.loads(f.read()) self.locales[lang] = json.loads(f.read())
case 'yaml' | 'yml': case 'yaml' | 'yml':
self.locales[lang] = yaml.full_load(f.read()) self.locales[lang] = yaml.safe_load(f.read())
def t(self, key: str, default: Any | None = None, **kwargs) -> str: def t(self, key: str, default: Any | None = None, **kwargs) -> str:
""" """
@@ -54,7 +70,7 @@ class I18n:
try: try:
translation = self.locales[self.current_language] translation = self.locales[self.current_language]
except KeyError: except KeyError:
keys = 'error.language_not_found' keys = 'error.language_not_found'.split('.')
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE] translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
for k in keys: for k in keys:
@@ -63,6 +79,7 @@ class I18n:
else: else:
# Pydantic 兼容 # Pydantic 兼容
translation = None if keys[0] == 'pydantic' else key translation = None if keys[0] == 'pydantic' else key
break
if translation and kwargs: if translation and kwargs:
translation = translation.format(**kwargs) translation = translation.format(**kwargs)
+79
View File
@@ -0,0 +1,79 @@
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from typing import Any, overload
from fastapi import FastAPI
from backend.common.enums import LifespanStage
LifespanFunc = Callable[[FastAPI], AbstractAsyncContextManager[dict[str, Any] | None]]
class LifespanManager:
"""FastAPI lifespan 管理器"""
def __init__(self) -> None:
self._lifespans: dict[LifespanStage, list[LifespanFunc]] = {
LifespanStage.core: [],
LifespanStage.plugin: [],
LifespanStage.tail: [],
}
@overload
def register(self, func: LifespanFunc) -> LifespanFunc: ...
@overload
def register(self, *, stage: LifespanStage) -> Callable[[LifespanFunc], LifespanFunc]: ...
def register(
self, func: LifespanFunc | None = None, *, stage: LifespanStage = LifespanStage.core
) -> LifespanFunc | Callable[[LifespanFunc], LifespanFunc]:
"""
注册 lifespan hook
:param func: lifespan hook直接装饰时使用
:param stage: 执行阶段控制粗粒度顺序默认为 core
:return:
"""
def decorator(f: LifespanFunc) -> LifespanFunc:
for hooks in self._lifespans.values():
for fn in hooks:
if fn is f:
return f
self._lifespans[stage].append(f)
return f
if func is not None:
return decorator(func)
return decorator
def build(self) -> LifespanFunc:
"""
构建组合后的 lifespan hook
:return:
"""
@asynccontextmanager
async def combined_lifespan(app: FastAPI): # noqa: ANN202
state: dict[str, Any] = {}
async with AsyncExitStack() as exit_stack:
for stage in LifespanStage:
for lifespan_fn in self._lifespans[stage]:
result = await exit_stack.enter_async_context(lifespan_fn(app))
if isinstance(result, dict):
state.update(result)
for key, value in state.items():
setattr(app.state, key, value)
yield state or None
return combined_lifespan
# 创建 lifespan_manager 单例
lifespan_manager = LifespanManager()
+31 -15
View File
@@ -4,6 +4,8 @@ import os
import re import re
import sys import sys
from typing import Any
from loguru import logger from loguru import logger
from backend.core.conf import settings from backend.core.conf import settings
@@ -35,16 +37,36 @@ class InterceptHandler(logging.Handler):
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def default_formatter(record: logging.LogRecord) -> str: def default_formatter(record: dict) -> str:
"""默认日志格式化程序""" """
默认日志格式化程序
:param record: Loguru Record 对象
:return:
"""
# 重写 sqlalchemy echo 输出 # 重写 sqlalchemy echo 输出
# https://github.com/sqlalchemy/sqlalchemy/discussions/12791 # https://github.com/sqlalchemy/sqlalchemy/discussions/12791
record_name = record['name'] or '' record_name = record['name'] or ''
if record_name.startswith('sqlalchemy'): if record_name.startswith('sqlalchemy'):
record['message'] = re.sub(r'\s+', ' ', record['message']).strip() record['message'] = re.sub(r'\s+', ' ', record['message']).strip()
return settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n' base_format = settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
if record.get('exception') is not None:
base_format += '{exception}\n'
return base_format
def request_id_filter(record: dict) -> bool:
"""
请求 ID 过滤器
:param record: Loguru Record 对象
:return:
"""
rid = get_request_trace_id()
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
return True
def setup_logging() -> None: def setup_logging() -> None:
@@ -75,22 +97,16 @@ def setup_logging() -> None:
# 移除 loguru 默认处理器 # 移除 loguru 默认处理器
logger.remove() logger.remove()
# request_id 过滤器
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
rid = get_request_trace_id()
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
return record
# 配置 loguru 处理器 # 配置 loguru 处理器
logger.configure( logger.configure(
handlers=[ handlers=[ # type: ignore[arg-type]
{ {
'sink': sys.stdout, 'sink': sys.stdout,
'level': settings.LOG_STD_LEVEL, 'level': settings.LOG_STD_LEVEL,
'format': default_formatter, 'format': default_formatter,
'filter': lambda record: request_id_filter(record), 'filter': lambda record: request_id_filter(record),
}, }
], ]
) )
@@ -108,12 +124,12 @@ def set_custom_logfile() -> None:
filename = filepath.split(os.sep)[-1] filename = filepath.split(os.sep)[-1]
original_filename = filename.split('.')[0] original_filename = filename.split('.')[0]
if '-' in original_filename: if '-' in original_filename:
return LOG_DIR / f'{original_filename}.log' return str(LOG_DIR / f'{original_filename}.log')
return LOG_DIR / f'{original_filename}_{timezone.now().strftime("%Y-%m-%d")}.log' return str(LOG_DIR / f'{original_filename}_{timezone.now().strftime("%Y-%m-%d")}.log')
# 日志文件通用配置 # 日志文件通用配置
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add # https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
log_config = { log_config: dict[str, Any] = {
'format': default_formatter, 'format': default_formatter,
'enqueue': True, 'enqueue': True,
'rotation': '00:00', 'rotation': '00:00',

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