Compare commits

..
273 Commits
Author SHA1 Message Date
Wu Clan 809879997c Update git and docker ignore files (#981) 2025-12-30 12:20:33 +08:00
Wu Clan b153b7ac18 Add observability instrument for httpx request (#980) 2025-12-30 12:16:59 +08:00
Wu Clan 308ccf71ae Bump dependencies and pre-commits (#979) 2025-12-29 15:47:46 +08:00
Wu Clan c8f6d9da72 Add the auto init project CLI command (#978) 2025-12-28 14:12:31 +08:00
Wu Clan 3f2c3e44ba Optimize login log database session calls (#977) 2025-12-25 18:12:43 +08:00
Wu Clan b5e2a9e591 Fix case where the user agent was empty (#976) 2025-12-25 12:03:57 +08:00
Wu Clan cdabe91092 Add OTEL semantic specification metrics config (#973) 2025-12-24 10:49:25 +08:00
Wu Clan 69a59993e3 Add observability instrument for redis client (#972) 2025-12-20 19:25:34 +08:00
Wu Clan b6defc671e Fix i18n target language error when concurrent (#970) 2025-12-20 19:25:05 +08:00
Wu Clan 8cbea57a8a Add an independent contribution document (#971) 2025-12-19 15:20:34 +08:00
Wu Clan 7845e1ff49 Fix support for special character passwords (#968)
* Fix special character password support in celery

* Fix celery amqp and socketio password

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

* Update configs

* Update docker script directory structure

* Fix the otel trace id

* Add grafana ini

* Fix some configs and loguru integration

* Add the celery grafana

* Update Grafana dashboards

* Update configs

* Fix issues with the panel

* Update grafana configs

* Update grafana dashboards

* Optimized panel styles

* Add sqlalchemy traces

* Fix the CORS

* Update the grafana query and config

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

* Update get sql script

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

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

* Update get data permission models

* Update date permission filter

* Optimize the target model logic

* Upgrade dependencies to use latest features

* Remove model warnings

* Fix the latest feature issues

* Fix the sqlalchemy Table class import

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

* Fix lint

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

* Update more

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

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

* Update the algorithm implementation

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

* Optimize some code definitions

* Update config comments

* Update the captcha check

* Update the config plugin sql scripts

* Add user password history model to init

* Fix some logic errors

* Add last_password_changed_time to user sql

* Fix user update password

* Fix the dynamic config check

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

* Add oauth2 state to binding

* Update oauth2 state

* Fix imports

* Update some interface definitions

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

* Revert of some changes

* More revert

* Update the user paginate

* Update user create and update

* Update dept select and delete

* Rename the join query functions

* Update select_join_serialize doc and README

* Fix typo in README

* Update the user delete

* Update the user social

* Update the dict plugin crud

* Update the dict plugin version

* Bump dependencies and pre-commits

* Update the code generator plugin crud

* Update the menu crud

* Update the role crud

* Update the data scope and rule crud

* Restore get_paginated to get_select

* Update the code generator plugin version

* Add the py version in pre-commit

* Remove the plugin include parameter config

* Add more cache cleaning TODO

* Rename get_with_relation to get_join

* Add the user cache clear

* Fix known compatibility issues

* Update the version number to 1.11.0

* Fix lint

* Optimize select_join_serialize logic

* Delete cache cleanup comments

* Update the oauth2 plugin version

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

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

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

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

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

* Bump fastapi to the latest version

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

* Fix opera log table username column length

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

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

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

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

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

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

* Delete the prints

* Fix lint

* Update models in code generation

* Delete print

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

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

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

* Fix lint

* Update paging interfaces

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

* Update context to custom ctx

* Fix the exception interception in opera log

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

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

* Update the per-file-ignores

* Update the ci

* Update rules

* Fix codes

* Fix pagination

* Update rules
2025-10-10 19:02:49 +08:00
Wu Clan 51354593d0 Bump dependencies and pre-commits (#845) 2025-10-09 13:26:56 +08:00
Wu Clan 8751425e2b Fix SQL script syntax error in config plugin (#843) 2025-09-27 12:33:58 +08:00
Wu Clan 921f05fa19 Fix the pgsql script in dict plugin (#842) 2025-09-27 12:17:45 +08:00
Wu Clan 5754c5449d Update the changelog for v1.8.2 (#841) 2025-09-26 13:32:05 +08:00
Wu Clan 2f535e3510 Update the version number to 1.8.2 (#840) 2025-09-26 13:28:41 +08:00
Wu Clan c25e447530 Update docker and deployment script comments (#839)
* Update docker and deployment script comments

* Update celery stdout logfile conf
2025-09-26 13:18:34 +08:00
Wu Clan 13359b736c Fix the phone filter in the user list (#838) 2025-09-26 12:08:53 +08:00
Wu Clan 500f20d91c Add extension parameters to paging data (#835) 2025-09-26 12:03:41 +08:00
Wu Clan d4de42fb46 Add the refresh token to demo mode exclusion (#834) 2025-09-26 12:03:27 +08:00
Wu Clan 3f0537a727 Simplify the code generation CLI parameters (#837) 2025-09-26 12:03:08 +08:00
Wu Clan d1cdb9e462 Fix the get model object method return logic (#832)
* Fix the get model object method return

* Update the error trigger method

* Delete the error log
2025-09-22 20:51:32 +08:00
Wu Clan 6c029b9be7 Fix code generation template missing Decimal (#830)
* Fix code generation template missing Decimal

* Update template rendering

* Update version number

* Fix lint
2025-09-22 10:46:13 +08:00
Wu Clan df12a0c701 Update the user agent in opera log model (#831) 2025-09-22 10:42:26 +08:00
Wu Clan 0be5685d9f Add version number output to the startup CLI (#820) 2025-09-17 11:11:50 +08:00
Wu Clan 3efca8af37 Update the dict plugin table structure (#817)
* Update the dict plugin table structure

* Update dict sql

* FIx the dict plugin sql
2025-09-15 09:04:30 +08:00
Wu Clan 82b2f808ac Fix the summary in opera log middleware (#816) 2025-09-14 13:22:24 +08:00
Wu Clan f417374f3e Update the login captcha verify logic (#815) 2025-09-12 21:02:21 +08:00
Wu Clan 237534c63e Update the startup progress information display (#812) 2025-09-12 15:44:17 +08:00
morty zhao b525124fdf Fix special character password support in alembic (#811) 2025-09-12 11:07:55 +08:00
Wu Clan 5217c95451 Fix summary default in opera log middleware (#809) 2025-09-11 18:21:40 +08:00
B ea973cac90 Fix pgsql syntax error in code generation (#808)
* Update title field in OperaLogSchemaBase to allow None values

* Update response type in get_all_tables to allow None values in dictionary

* Fix code generation error in PostgreSQL

* Update opera_log.py
2025-09-11 17:53:15 +08:00
Wu Clan 1bedda0e66 Update the offline location information parse (#807) 2025-09-11 14:49:09 +08:00
B c1087fa51d Fix the subprocess ensurepip got stuck in Linux (#806)
* Fix ensurepip_install

* fix code format
2025-09-10 19:42:13 +08:00
Wu Clan cfddf44e7e Update the swagger docs version to dynamic (#805) 2025-09-09 19:36:33 +08:00
Wu Clan 085b32e921 Update the changelog for v1.8.1 (#804) 2025-09-09 19:19:30 +08:00
Wu Clan 2099c33aad Fix alembic migration file missing import (#803) 2025-09-09 17:21:23 +08:00
yzbf b809497aba Fix the subprocess check call failed in windows (#802)
* fix: 修复Windows下subprocess调用失败的问题

* fix: 修复代码格式问题
2025-09-09 16:21:51 +08:00
Wu Clan a6fe8c0e8d Fix console import in main module (#800) 2025-09-07 19:33:55 +08:00
Wu Clan 629cf7ce45 Optimize the code generation interaction logic (#799) 2025-09-06 19:24:02 +08:00
Wu Clan fad6c05d41 Add the code generator to the CLI (#798)
* Add the code generator to the CLI

* Optimize run_await implementation

* Optimize interactions
2025-09-05 23:43:05 +08:00
Wu Clan 77d59d3453 Add schema for the tree data (#796) 2025-09-04 16:38:53 +08:00
Wu Clan 931d6b281a Fix the psycopg dependency naming (#795) 2025-09-04 11:54:39 +08:00
Wu Clan d929f0dbdc Fix sql script syntax error in config plugin (#793) 2025-09-03 19:57:11 +08:00
Wu Clan 6d1b47903e Add the dict type query all interface (#794) 2025-09-03 19:56:56 +08:00
Wu Clan 8420ccf190 Update the notice model and pagination (#792) 2025-09-02 15:51:07 +08:00
Wu Clan 1e38e1918b Bump dependencies and pre-commits (#791) 2025-09-02 12:45:34 +08:00
IAseven 52e78152a1 Fix the tzinfo comparison in the timezone type (#787) 2025-08-29 10:30:42 +08:00
Wu Clan dcb07c60f2 Add the Google OAuth2 login (#790) 2025-08-28 22:10:22 +08:00
Wu Clan 33c9f78cf0 Update the handling of CORS 500 status code (#789) 2025-08-28 15:36:06 +08:00
Wu Clan 37b1033697 Update the opera log exception message record (#788) 2025-08-28 15:01:01 +08:00
Wu Clan 0aa4ebc635 Update the model datetime column type to custom (#786)
* Update the model datetime column type to custom

* Update the schema datetime filed json encoder
2025-08-27 20:07:35 +08:00
language 30d4107670 Fix CLI openapi url in production environment (#785) 2025-08-26 09:39:30 +08:00
Wu Clan dcf235db78 Fix code generation template params and filename (#784) 2025-08-22 13:20:30 +08:00
Wu Clan 1dfcd7ae3c Update the celery task result table creation logic (#783)
* Update the celery task result table creation logic

* Disable beat_sync_every config

* Update the prepared comment
2025-08-21 23:46:52 +08:00
wuyao4 2b28244099 Fix the plugin model object detection logic (#782) 2025-08-21 18:17:50 +08:00
Wu Clan f71071f905 Fix the celery beat distributed lock timeout (#779)
* Fix the celery beat distributed lock timeout

* Fix redis client not close after plugin parse

* Optimize code layout and lock extend
2025-08-21 13:50:57 +08:00
Wu Clan 34c9c398ca Fix redis client not close after plugin parse (#780) 2025-08-21 10:58:53 +08:00
Wu Clan f099777dd3 Fix the docker compose env configuration file (#778)
* Fix the docker compose env configuration file

* Fix CELERY_RABBITMQ_HOST value
2025-08-19 13:21:11 +08:00
Wu Clan c95be82cb9 Update the env parameter value in env file (#777) 2025-08-19 12:50:08 +08:00
Wu Clan 00ec039095 Add test data SQL script for config plugin (#776) 2025-08-19 12:38:22 +08:00
Wu Clan 4d087355b4 Optimize email sending config update logic (#775) 2025-08-19 12:19:31 +08:00
Wu Clan d57fabcbb4 Add bulk update interface for config plugin (#774)
* Add bulk update interface for config plugin

* Remove email sending update
2025-08-19 12:18:38 +08:00
yanlingsishao 3a32522e2b Optimize the celery crontab expression validation (#773) 2025-08-18 22:19:59 +08:00
Wu Clanandwu-clan 22f8850bf7 Update the changelog for v1.8.0 (#772)
This commit was created by changelog-from-release in 'Release changelog' CI workflow

Co-authored-by: wu-clan <wu-clan@users.noreply.github.com>
2025-08-15 20:09:28 +08:00
Wu Clan eeb709c6aa Update the version number to 1.8.0 (#771) 2025-08-15 20:06:21 +08:00
Wu Clan a6bbf2971d Update the menu title in SQL scripts (#770) 2025-08-15 19:58:46 +08:00
Dylan cd48bb4210 Add i18n support for response message (#753)
* feat: i18n support

* Optimize i18n

* Update the locale in the code

* Update the zh-CN file

* Update the en-US file

* Update the reload filter

* Update locale success plugin value

* Fix lint

* Update pydantic error message translation

* Update to minimal implementation

* Fix minimal missing code
2025-08-15 19:57:10 +08:00
Wu Clan 4500dd0128 Add a standalone email sending plugin (#769) 2025-08-13 18:09:42 +08:00
Wu Clan 2b6d8222ad Update the content layout of the config file (#768) 2025-08-13 18:07:02 +08:00
Wu Clan 1b47ab7e83 Optimize the timezone datetime return encoder (#767)
* Optimize the timezone datetime return encoder

* Update the default datetime conversion
2025-08-13 11:16:58 +08:00
Wu Clan bd804e0a38 Update the description for the run file (#766) 2025-08-12 23:28:43 +08:00
Wu Clan 8e8af2032a Optimize naming and preview in code generation (#764) 2025-08-12 16:42:25 +08:00
IAseven e09062eb39 Optimize the opera log storage logic through queue (#750)
*  feat: 操作日志中间件添加批量插入功能

* Delete GEMINI.md

* 🌈 style: 修复格式化错误

* 🐞 fix: 通过asyncio.wait_for兼容py3.10中asyncio.timeout不存在

* 🦄 refactor: 重新组织操作日志批量插入代码逻辑

* 优化代码实现

* 恢复默认配置

* 恢复默认 .gitignore 文件

* 更新队列批处理逻辑
2025-08-07 17:36:10 +08:00
Wu Clan 8c00492e44 Update the naming of table creation function (#760) 2025-08-07 16:57:57 +08:00
Wu Clan 0237d4c7b1 Update log output config and format (#759) 2025-08-07 16:46:56 +08:00
Wu Clan fe3a3b4e86 Optimize the data sort logic of tree nodes (#758) 2025-08-06 18:23:09 +08:00
Wu Clan 65ec721a1c Add business pagination in the code generator (#757) 2025-08-05 21:04:27 +08:00
Wu Clan 4eb76ad6ea Update the opera log desensitization method (#756)
* Update the opera log desensitization method

* Update request args function return
2025-08-05 18:38:49 +08:00
Wu Clan 1f8687155a Fix message format in validation exception handler (#755) 2025-08-05 18:11:02 +08:00
Wu Clan 8591d4e592 Refactor task routes and add control routes (#749) 2025-08-04 13:18:07 +08:00
Wu Clan dedf4e7bae Refactor code generation files and routes (#748)
* Refactor code generation files and routes

* Fix lint
2025-08-04 13:16:41 +08:00
Wu Clan 1e4aa88487 Fix the kwargs params of schedule task (#747) 2025-08-04 13:15:48 +08:00
Wu Clan 0dd745b7a7 Add schedule task demo that contains params (#746) 2025-07-31 18:29:54 +08:00
Wu Clan 6b2402f212 Add some interfaces for user profiles (#745) 2025-07-31 16:46:02 +08:00
Wu Clan 24a487eeea Simplify the plugin status update logic (#744) 2025-07-30 11:31:37 +08:00
Wu Clan 4f574189c7 Fix the error trigger when model auto import (#743) 2025-07-29 22:52:34 +08:00
Wu Clan b559a74cea Add update support for user email and phone (#742) 2025-07-29 22:52:08 +08:00
Wu Clan 83dcdbe59d Update the OAuth2 login password policy (#741)
* Update the OAuth2 login password policy

* Update the crud pwd

* Update the reset pwd service
2025-07-29 22:51:31 +08:00
Wu Clan d64f7c2911 Fix the opera log field encryption (#739) 2025-07-25 19:23:36 +08:00
Wu Clan 53e64bce37 Add auth whitelist regular expression config (#738) 2025-07-24 21:34:24 +08:00
Wu Clan 00a781357b Fix celery CLI option to required (#737) 2025-07-24 21:32:57 +08:00
Wu Clan d7f87ed0ad Update the default cache period for userinfo (#734) 2025-07-21 21:31:55 +08:00
Wu Clan bda9b1d463 Add distributed lock for scheduled task (#732)
* Add distributed lock for scheduled task

* Add the task to extend lock

* Fix the close
2025-07-21 12:29:12 +08:00
Wu Clan e0a106ec51 Simplify task crontab expression validation (#733) 2025-07-18 21:11:54 +08:00
Wu Clan 016361bd68 Update the changelog for v1.7.0 (#729) 2025-07-16 13:40:55 +08:00
Wu Clan f2d3c39425 Fix login and operation log clearing (#728) 2025-07-16 13:34:37 +08:00
Wu Clan e45d2d6fe7 Add CLI support for startup celery services (#724)
* Add CLI support for startup celery services

* update the cli file
2025-07-16 12:28:57 +08:00
Wu Clan 4ddf84fa22 Bump granian from 2.4.0 to 2.4.2 (#727) 2025-07-16 12:28:02 +08:00
Wu Clan 326a1883e8 Fix the parsing of execution task params (#725)
* Fix the parsing of execution task params

* Enhanced checksums
2025-07-16 12:10:33 +08:00
Wu Clan 9ff36d4498 Delete the default value of schema enum data (#723) 2025-07-15 10:43:58 +08:00
Wu Clan 98ef07ad32 Simplify celery task crontab config (#722) 2025-07-15 00:28:10 +08:00
Wu Clan 802b0d456a Optimize celery integrations and events (#721) 2025-07-14 19:17:24 +08:00
Wu Clan 6767f0e2e6 Update the celery task comment and name (#720) 2025-07-11 21:08:56 +08:00
Wu Clan d72a05c965 Fix the celery task scheduler query (#719) 2025-07-11 21:08:44 +08:00
Wu Clan ce3be1db8e Add support for celery dynamic tasks (#715)
* Add support for celery dynamic tasks

* Update the celery conf

* Update the celery task tables name

* Refactor the celery task-related interfaces

* Optimize auto-discovery tasks

* Remove redundant config

* Refine the business codes

* Optimize crontab validation returns

* Update dependencies in pyproject toml

* Fix some bugs

* Update dependencies

* Update the version to 1.7.0

* Fix update and delete event
2025-07-11 07:54:33 +08:00
Wu Clan e84ef04f15 Update the CLI startup service mode (#718) 2025-07-10 20:33:50 +08:00
Wu Clan adee3a2177 Simplify user permission database queries (#717) 2025-07-09 20:44:32 +08:00
Wu Clan 526e0aab9a Optimize the analysis of get plugins (#716)
* Optimize the analysis of get plugins

* update var
2025-07-08 12:20:13 +08:00
Wu Clan 2bbbbe764a Update the log output default style (#714)
* Update the log output default style

* Update the log file compression

* fix line

* Add log summary
2025-07-06 14:32:36 +08:00
Wu Clan ef5e921c08 Update the middleware logging accuracy (#713)
* Update the middleware logging accuracy

* Update the log style
2025-07-05 19:52:37 +08:00
Wu Clan a2fa59285a Update the granian env to command params (#712) 2025-07-04 19:46:40 +08:00
Wu Clan 494942e87e Add CLI support for execute sql scripts (#711)
* Add CLI support for execute sql scripts

* Update the arg helps
2025-07-02 20:19:29 +08:00
Wu Clan aa2b76673f Update the reload excludes for CLI run (#709) 2025-07-02 18:16:43 +08:00
Wu Clan 099880dd1c Update the refresh token verify mechanism (#710)
* Remove the logout interface auth dependency

* Update the refresh token check
2025-07-02 18:08:28 +08:00
Wu Clan d906a103af Fix the code generation delete schema template (#708) 2025-07-01 22:28:37 +08:00
Wu Clan 4ed49d2d13 Replace gunicorn deployment to granian (#705)
* Replace gunicorn deployment to granian

* Update env comments

* Fix the granian env
2025-07-01 21:33:49 +08:00
Wu Clan 7b5ae4696f Fix the code generation schema template (#706) 2025-07-01 18:18:47 +08:00
Wu Clan 54ea301152 Update the CLI to be executed async (#704) 2025-07-01 18:18:29 +08:00
Wu Clan 69c27232ac Update the changelog for v1.6.0 (#703) 2025-06-30 17:02:55 +08:00
Wu Clan f36dcb3f5c Update the version number to 1.6.0 (#702) 2025-06-30 17:00:19 +08:00
Wu Clan 63d088d62c Update the Dockerfile to adapt the CLI (#701) 2025-06-30 16:53:16 +08:00
Wu Clan a461f78224 Optimize the installation of plugin dependencies (#700)
* Optimize the installation of plugin dependencies

* Remove enumerate

* Update main params
2025-06-30 09:29:35 +08:00
Wu Clan c306432708 Update the help for CLI run worker (#699) 2025-06-29 20:06:45 +08:00
Wu Clan 97f778cc90 Add CLI support for plugin install (#698)
* Add CLI support for plugin install

* Fix some usage errors

* Update prompt information
2025-06-29 17:41:12 +08:00
Wu Clan 88695ac6ad Add custom CLI for service startup (#697)
* Add custom CLI for service startup

* Remove redundant scripts

* Fix lint
2025-06-28 14:56:19 +08:00
Wu Clan 3b04329b04 Add the test user to SQL scripts (#696) 2025-06-27 18:43:42 +08:00
Wu Clan bd4acf8121 Update the extension plugin config (#695) 2025-06-27 18:14:25 +08:00
Wu Clan b9a9b1efe6 Update the SQL to adapt frontend plugin (#694) 2025-06-26 21:58:35 +08:00
Wu Clan c050f58ee9 Fix the OAuth2 redirect route names (#693) 2025-06-26 21:50:22 +08:00
Wu Clan d47375ae41 Optimize dict create and update logic (#691)
* Optimize dict create and update logic

* Update version
2025-06-25 10:41:09 +08:00
Wu Clan 0602c6144d Update the changelog for v1.5.2 (#690) 2025-06-24 17:40:09 +08:00
Wu Clan c84f0658fb Update the dict pagination query parameters (#689)
* Update the dict pagination query parameters

* Update the dict pagination query parameters

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

* Update plugin sql scripts

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

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

* Add codes endpoint description

* Update jinja templates

* fix typo

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

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

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

* 修复错误引用

* 添加雪花详情链接

* feat: add snowflake ID parser method

* 修复独立执行异常

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

* Update filter style

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

* update init sql
2025-06-09 19:58:06 +08:00
Wu Clan a4c7dae99c Update the plugin download interface permission (#659) 2025-06-09 19:10:48 +08:00
Wu Clan bfda0a226c Update code generate interface permission (#658) 2025-06-09 19:10:40 +08:00
Wu Clan c9bbb105a3 Update data scope and rule to m2m (#657) 2025-06-09 19:10:15 +08:00
Wu Clan b415dc8912 Add OAuth2 user to auto bind a role (#656) 2025-06-08 20:18:24 +08:00
Wu Clan 05b74d71a3 Simplify OAuth2 model and optimize auth service (#655) 2025-06-08 16:51:19 +08:00
Wu Clan 10c0c691a7 Update user email and phone operation logic (#654) 2025-06-08 16:43:51 +08:00
Wu Clan 0bc3786fc6 Update OAuth2 callback interface return (#653) 2025-06-06 20:35:25 +08:00
Wu Clan 8e041ccb1b Update the changelog for v1.4.3 (#651) 2025-06-02 20:43:12 +08:00
Wu Clan a82e9b7b9d Fix data permission condition for filter data (#650) 2025-06-01 00:51:31 +08:00
Wu Clan 7bab8cc636 Update default value for role filter scopes (#649) 2025-05-31 23:53:47 +08:00
Wu Clan 7659b63d88 Add data permission condition for filter data (#648) 2025-05-31 23:40:59 +08:00
Wu Clan 33ebc5723f Add get all data scope rules interface (#647) 2025-05-31 23:09:26 +08:00
Wu Clan 86d9efd785 Update the server startup time to string (#646) 2025-05-31 17:26:07 +08:00
Wu Clan 57ae7f03a6 Refactor routes to better align with RESTful (#645) 2025-05-31 17:01:44 +08:00
Wu Clan 6ee3983846 Fix the filter of query all menus (#642) 2025-05-30 22:48:30 +08:00
Wu Clan f05ed5cdda Fix the menu delete interface arg description (#641) 2025-05-30 18:58:33 +08:00
Wu Clan 187dfa5344 Fix the role update business variables (#640) 2025-05-30 18:56:50 +08:00
Wu Clan 918c8a9d33 Update the changelog for v1.4.2 (#639) 2025-05-29 18:12:45 +08:00
374 changed files with 19131 additions and 7582 deletions
+3
View File
@@ -3,7 +3,10 @@ __pycache__/
.github/ .github/
.idea/ .idea/
.vscode/ .vscode/
.cursor/
venv/ venv/
.venv/ .venv/
.ruff_cache/ .ruff_cache/
.pytest_cache/ .pytest_cache/
.claude/
.serena/
+2
View File
@@ -18,4 +18,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 }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: lint ${{ matrix.python-version }} name: lint ${{ matrix.python-version }}
strategy: strategy:
matrix: matrix:
python-version: [ '3.10', '3.11', '3.12', '3.13' ] python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+2
View File
@@ -7,3 +7,5 @@ venv/
.python-version .python-version
.ruff_cache/ .ruff_cache/
.pytest_cache/ .pytest_cache/
.claude/
.serena/
+9 -8
View File
@@ -1,26 +1,27 @@
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: v5.0.0 rev: v6.0.0
hooks: hooks:
# - id: check-added-large-files - 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/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.11.9 rev: v0.14.10
hooks: hooks:
- id: ruff - id: ruff-check
args: args:
# https://github.com/astral-sh/ruff-pre-commit/issues/64 # https://github.com/astral-sh/ruff-pre-commit/issues/64
- '--config'
- 'backend/.ruff.toml'
- '--fix' - '--fix'
- '--unsafe-fixes' - '--unsafe-fixes'
- 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.7.3 rev: 0.9.18
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+174
View File
@@ -0,0 +1,174 @@
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"
+696 -1
View File
@@ -1,3 +1,613 @@
<a id="v1.12.0"></a>
# [v1.12.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.0) - 2025-12-15
## What's Changed
* Update changelog for v1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#942](https://github.com/fastapi-practices/fastapi_best_architecture/pull/942)
* Update celery related docker container independence by [@wu-clan](https://github.com/wu-clan) in [#943](https://github.com/fastapi-practices/fastapi_best_architecture/pull/943)
* Fix super value in update user permissions by [@wu-clan](https://github.com/wu-clan) in [#948](https://github.com/fastapi-practices/fastapi_best_architecture/pull/948)
* Optimize data permission logic and usage by [@wu-clan](https://github.com/wu-clan) in [#947](https://github.com/fastapi-practices/fastapi_best_architecture/pull/947)
* Update pre-commit to prek in pre-commit script by [@wu-clan](https://github.com/wu-clan) in [#949](https://github.com/fastapi-practices/fastapi_best_architecture/pull/949)
* Optimize the coupling of user social plugin by [@wu-clan](https://github.com/wu-clan) in [#950](https://github.com/fastapi-practices/fastapi_best_architecture/pull/950)
* Add the database primary key mode config by [@wu-clan](https://github.com/wu-clan) in [#953](https://github.com/fastapi-practices/fastapi_best_architecture/pull/953)
* Optimize the coupling of code generation CLI by [@wu-clan](https://github.com/wu-clan) in [#951](https://github.com/fastapi-practices/fastapi_best_architecture/pull/951)
* Add CLI init project database support by [@wu-clan](https://github.com/wu-clan) in [#952](https://github.com/fastapi-practices/fastapi_best_architecture/pull/952)
* Update the init project database CLI to subcommand by [@wu-clan](https://github.com/wu-clan) in [#954](https://github.com/fastapi-practices/fastapi_best_architecture/pull/954)
* Fix CLI command for code generation by [@wu-clan](https://github.com/wu-clan) in [#956](https://github.com/fastapi-practices/fastapi_best_architecture/pull/956)
* Fix the IP address in the request log by [@wuyuemushi](https://github.com/wuyuemushi) in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
* Add the Grafana observability suite by [@wu-clan](https://github.com/wu-clan) in [#961](https://github.com/fastapi-practices/fastapi_best_architecture/pull/961)
* Update the version number to 1.12.0 by [@wu-clan](https://github.com/wu-clan) in [#962](https://github.com/fastapi-practices/fastapi_best_architecture/pull/962)
## New Contributors
* [@wuyuemushi](https://github.com/wuyuemushi) made their first contribution in [#959](https://github.com/fastapi-practices/fastapi_best_architecture/pull/959)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
<a href="https://github.com/wuyuemushi"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwuyuemushi.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wuyuemushi"></a>
[Changes][v1.12.0]
<a id="v1.11.2"></a>
# [v1.11.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.2) - 2025-11-28
## What's Changed
* Update changelog for v1.11.1 by [@wu-clan](https://github.com/wu-clan) in [#923](https://github.com/fastapi-practices/fastapi_best_architecture/pull/923)
* Fix typos in data rule sql scripts by [@wu-clan](https://github.com/wu-clan) in [#926](https://github.com/fastapi-practices/fastapi_best_architecture/pull/926)
* Restore captcha uuid naming in the login params by [@wu-clan](https://github.com/wu-clan) in [#928](https://github.com/fastapi-practices/fastapi_best_architecture/pull/928)
* Add distributed deployment support for snowflake ID by [@downdawn](https://github.com/downdawn) in [#927](https://github.com/fastapi-practices/fastapi_best_architecture/pull/927)
* Add env reqs for plugin install and uninstall by [@wu-clan](https://github.com/wu-clan) in [#929](https://github.com/fastapi-practices/fastapi_best_architecture/pull/929)
* Optimize the use of some LRU caches by [@wu-clan](https://github.com/wu-clan) in [#932](https://github.com/fastapi-practices/fastapi_best_architecture/pull/932)
* Update the i18n language file init location by [@wu-clan](https://github.com/wu-clan) in [#934](https://github.com/fastapi-practices/fastapi_best_architecture/pull/934)
* Fix get column types in code generation by [@wu-clan](https://github.com/wu-clan) in [#935](https://github.com/fastapi-practices/fastapi_best_architecture/pull/935)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#936](https://github.com/fastapi-practices/fastapi_best_architecture/pull/936)
* Update the files interface filename to file by [@wu-clan](https://github.com/wu-clan) in [#937](https://github.com/fastapi-practices/fastapi_best_architecture/pull/937)
* Update task application interface definitions by [@wu-clan](https://github.com/wu-clan) in [#938](https://github.com/fastapi-practices/fastapi_best_architecture/pull/938)
* Update code generation interface definitions by [@wu-clan](https://github.com/wu-clan) in [#939](https://github.com/fastapi-practices/fastapi_best_architecture/pull/939)
* Update the fba run CLI command output by [@wu-clan](https://github.com/wu-clan) in [#941](https://github.com/fastapi-practices/fastapi_best_architecture/pull/941)
* Update the version number to 1.11.2 by [@wu-clan](https://github.com/wu-clan) in [#940](https://github.com/fastapi-practices/fastapi_best_architecture/pull/940)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
## Contributors
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.11.2]
<a id="v1.11.1"></a>
# [v1.11.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.1) - 2025-11-16
## What's Changed
* Update changelog for v1.11.0 by [@wu-clan](https://github.com/wu-clan) in [#917](https://github.com/fastapi-practices/fastapi_best_architecture/pull/917)
* Fix missing table in alembic migration by [@wu-clan](https://github.com/wu-clan) in [#920](https://github.com/fastapi-practices/fastapi_best_architecture/pull/920)
* Add user social binding and unbinding by [@wu-clan](https://github.com/wu-clan) in [#919](https://github.com/fastapi-practices/fastapi_best_architecture/pull/919)
* Fix the user list query serialization by [@linrong](https://github.com/linrong) in [#921](https://github.com/fastapi-practices/fastapi_best_architecture/pull/921)
* Update user and login security configs by [@wu-clan](https://github.com/wu-clan) in [#922](https://github.com/fastapi-practices/fastapi_best_architecture/pull/922)
## New Contributors
* [@linrong](https://github.com/linrong) made their first contribution in [#921](https://github.com/fastapi-practices/fastapi_best_architecture/pull/921)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.0...v1.11.1
## Contributors
<a href="https://github.com/linrong"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Flinrong.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@linrong"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.11.1]
<a id="v1.11.0"></a>
# [v1.11.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.11.0) - 2025-11-12
## What's Changed
* Update changelog for v1.10.4 by [@wu-clan](https://github.com/wu-clan) in [#916](https://github.com/fastapi-practices/fastapi_best_architecture/pull/916)
* Refactor foreign keys and relationships to pure logic by [@wu-clan](https://github.com/wu-clan) in [#901](https://github.com/fastapi-practices/fastapi_best_architecture/pull/901)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.4...v1.11.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.11.0]
<a id="v1.10.4"></a>
# [v1.10.4](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.4) - 2025-11-12
## What's Changed
* Update changelog for v1.10.3 by [@wu-clan](https://github.com/wu-clan) in [#895](https://github.com/fastapi-practices/fastapi_best_architecture/pull/895)
* Bump fastapi oauth2 from 0.0.1 to 0.0.2 by [@wu-clan](https://github.com/wu-clan) in [#896](https://github.com/fastapi-practices/fastapi_best_architecture/pull/896)
* Update the interface timing accuracy in log by [@wu-clan](https://github.com/wu-clan) in [#897](https://github.com/fastapi-practices/fastapi_best_architecture/pull/897)
* Optimize redis batch get and delete operations by [@wu-clan](https://github.com/wu-clan) in [#899](https://github.com/fastapi-practices/fastapi_best_architecture/pull/899)
* Update the time column type in the task by [@wu-clan](https://github.com/wu-clan) in [#900](https://github.com/fastapi-practices/fastapi_best_architecture/pull/900)
* Add the user social independent enum file by [@wu-clan](https://github.com/wu-clan) in [#902](https://github.com/fastapi-practices/fastapi_best_architecture/pull/902)
* Optimize the request params of the service layer by [@wu-clan](https://github.com/wu-clan) in [#903](https://github.com/fastapi-practices/fastapi_best_architecture/pull/903)
* Optimize the data permission condition build by [@wu-clan](https://github.com/wu-clan) in [#904](https://github.com/fastapi-practices/fastapi_best_architecture/pull/904)
* Fix response status codes in the request logs by [@wu-clan](https://github.com/wu-clan) in [#905](https://github.com/fastapi-practices/fastapi_best_architecture/pull/905)
* Add dept validation to user updates by [@wu-clan](https://github.com/wu-clan) in [#906](https://github.com/fastapi-practices/fastapi_best_architecture/pull/906)
* Update the version number to 1.10.4 by [@wu-clan](https://github.com/wu-clan) in [#907](https://github.com/fastapi-practices/fastapi_best_architecture/pull/907)
* Fix typo in the data permission prompt by [@wu-clan](https://github.com/wu-clan) in [#909](https://github.com/fastapi-practices/fastapi_best_architecture/pull/909)
* Fix user cache cleanup when operating data rules by [@wu-clan](https://github.com/wu-clan) in [#910](https://github.com/fastapi-practices/fastapi_best_architecture/pull/910)
* Fix create and delete department validations by [@wu-clan](https://github.com/wu-clan) in [#911](https://github.com/fastapi-practices/fastapi_best_architecture/pull/911)
* Fix the user menu sidebar parsing by [@wu-clan](https://github.com/wu-clan) in [#912](https://github.com/fastapi-practices/fastapi_best_architecture/pull/912)
* Add user social unbinding account interface by [@wu-clan](https://github.com/wu-clan) in [#913](https://github.com/fastapi-practices/fastapi_best_architecture/pull/913)
* Fix user cache cleanup when updating data scope by [@wu-clan](https://github.com/wu-clan) in [#915](https://github.com/fastapi-practices/fastapi_best_architecture/pull/915)
* Fix dept and menu table subqueries by [@wu-clan](https://github.com/wu-clan) in [#914](https://github.com/fastapi-practices/fastapi_best_architecture/pull/914)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.4]
<a id="v1.10.3"></a>
# [v1.10.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.3) - 2025-10-30
## What's Changed
* Update changelog for v1.10.2 by [@wu-clan](https://github.com/wu-clan) in [#873](https://github.com/fastapi-practices/fastapi_best_architecture/pull/873)
* Fix docker default database env variables by [@wu-clan](https://github.com/wu-clan) in [#874](https://github.com/fastapi-practices/fastapi_best_architecture/pull/874)
* Add port for fba sever in docker compose by [@wu-clan](https://github.com/wu-clan) in [#875](https://github.com/fastapi-practices/fastapi_best_architecture/pull/875)
* Update the container naming in docker scripts by [@wu-clan](https://github.com/wu-clan) in [#876](https://github.com/fastapi-practices/fastapi_best_architecture/pull/876)
* Fix the httpurl type compatibility with postgresql by [@wu-clan](https://github.com/wu-clan) in [#877](https://github.com/fastapi-practices/fastapi_best_architecture/pull/877)
* Update the default length of user email column by [@wu-clan](https://github.com/wu-clan) in [#878](https://github.com/fastapi-practices/fastapi_best_architecture/pull/878)
* Update the serializer of httpurl type by [@wu-clan](https://github.com/wu-clan) in [#879](https://github.com/fastapi-practices/fastapi_best_architecture/pull/879)
* Fix the OAuth2 link acquisition in HTTPS by [@wu-clan](https://github.com/wu-clan) in [#881](https://github.com/fastapi-practices/fastapi_best_architecture/pull/881)
* Add Google OAuth2 callback to opera log exclusion by [@wu-clan](https://github.com/wu-clan) in [#882](https://github.com/fastapi-practices/fastapi_best_architecture/pull/882)
* Update the length style of the model columns by [@wu-clan](https://github.com/wu-clan) in [#883](https://github.com/fastapi-practices/fastapi_best_architecture/pull/883)
* Bump dependencies to the latest version by [@wu-clan](https://github.com/wu-clan) in [#890](https://github.com/fastapi-practices/fastapi_best_architecture/pull/890)
* Fix import in code generation api template by [@wu-clan](https://github.com/wu-clan) in [#891](https://github.com/fastapi-practices/fastapi_best_architecture/pull/891)
* Fix celery compatibility with psycopg version by [@wu-clan](https://github.com/wu-clan) in [#892](https://github.com/fastapi-practices/fastapi_best_architecture/pull/892)
* Fix the venv pip availability in Linux by [@wu-clan](https://github.com/wu-clan) in [#893](https://github.com/fastapi-practices/fastapi_best_architecture/pull/893)
* Add the celery rabbitmq vhost config by [@wu-clan](https://github.com/wu-clan) in [#894](https://github.com/fastapi-practices/fastapi_best_architecture/pull/894)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.3]
<a id="v1.10.2"></a>
# [v1.10.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.2) - 2025-10-21
## What's Changed
* Update the changelog for v1.10.1 by [@wu-clan](https://github.com/wu-clan) in [#869](https://github.com/fastapi-practices/fastapi_best_architecture/pull/869)
* Bump rtoml and uvicorn to support python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#871](https://github.com/fastapi-practices/fastapi_best_architecture/pull/871)
* Optimize sqlalchemy types to simplify compatibility by [@wu-clan](https://github.com/wu-clan) in [#870](https://github.com/fastapi-practices/fastapi_best_architecture/pull/870)
* Bump fastapi to remove warning for python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#872](https://github.com/fastapi-practices/fastapi_best_architecture/pull/872)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.2]
<a id="v1.10.1"></a>
# [v1.10.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.1) - 2025-10-18
## What's Changed
* Update the changelog for v1.10.0 by [@wu-clan](https://github.com/wu-clan) in [#864](https://github.com/fastapi-practices/fastapi_best_architecture/pull/864)
* Fix tasks related to deleting db logs by [@wu-clan](https://github.com/wu-clan) in [#863](https://github.com/fastapi-practices/fastapi_best_architecture/pull/863)
* Fix the volume in the docker compose script by [@wu-clan](https://github.com/wu-clan) in [#865](https://github.com/fastapi-practices/fastapi_best_architecture/pull/865)
* Update the release changelog workflow by [@wu-clan](https://github.com/wu-clan) in [#867](https://github.com/fastapi-practices/fastapi_best_architecture/pull/867)
* Bump dependencies to the latest version by [@wu-clan](https://github.com/wu-clan) in [#868](https://github.com/fastapi-practices/fastapi_best_architecture/pull/868)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.0...v1.10.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.1]
<a id="v1.10.0"></a>
# [v1.10.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.10.0) - 2025-10-17
## What's Changed
* Fix the import table cli command by [@wu-clan](https://github.com/wu-clan) in [#860](https://github.com/fastapi-practices/fastapi_best_architecture/pull/860)
* Update unique judgment logic for dict data by [@wu-clan](https://github.com/wu-clan) in [#859](https://github.com/fastapi-practices/fastapi_best_architecture/pull/859)
* Fix the api ninja in code generation by [@wu-clan](https://github.com/wu-clan) in [#858](https://github.com/fastapi-practices/fastapi_best_architecture/pull/858)
* Fix dict plugin pgsql init script missing by [@wu-clan](https://github.com/wu-clan) in [#857](https://github.com/fastapi-practices/fastapi_best_architecture/pull/857)
* Update the changelog for v1.9.0 by [@wu-clan](https://github.com/wu-clan) in [#856](https://github.com/fastapi-practices/fastapi_best_architecture/pull/856)
* Fix ctx in validation exception handler by [@wu-clan](https://github.com/wu-clan) in [#861](https://github.com/fastapi-practices/fastapi_best_architecture/pull/861)
* Update the primary database to postgresql by [@wu-clan](https://github.com/wu-clan) in [#829](https://github.com/fastapi-practices/fastapi_best_architecture/pull/829)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.9.0...v1.10.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.10.0]
<a id="v1.9.0"></a>
# [v1.9.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.9.0) - 2025-10-16
## What's Changed
* Update changelog for v1.8.3 by [@wu-clan](https://github.com/wu-clan) in [#849](https://github.com/fastapi-practices/fastapi_best_architecture/pull/849)
* Bump fastapi and pydantic to the latest by [@wu-clan](https://github.com/wu-clan) in [#851](https://github.com/fastapi-practices/fastapi_best_architecture/pull/851)
* Update superuser verify to dependency injection by [@wu-clan](https://github.com/wu-clan) in [#852](https://github.com/fastapi-practices/fastapi_best_architecture/pull/852)
* Fix the superuser verify missing auth by [@wu-clan](https://github.com/wu-clan) in [#854](https://github.com/fastapi-practices/fastapi_best_architecture/pull/854)
* Update request state usage to context variable by [@wu-clan](https://github.com/wu-clan) in [#853](https://github.com/fastapi-practices/fastapi_best_architecture/pull/853)
* Fix safely of access ctx in exception handlers by [@wu-clan](https://github.com/wu-clan) in [#855](https://github.com/fastapi-practices/fastapi_best_architecture/pull/855)
* Refactor the service layer db session call method by [@wu-clan](https://github.com/wu-clan) in [#850](https://github.com/fastapi-practices/fastapi_best_architecture/pull/850)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.3...v1.9.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.9.0]
<a id="v1.8.3"></a>
# [v1.8.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.3) - 2025-10-13
## What's Changed
* Update changelog for v1.8.2 by [@wu-clan](https://github.com/wu-clan) in [#841](https://github.com/fastapi-practices/fastapi_best_architecture/pull/841)
* Fix the pgsql script in dict plugin by [@wu-clan](https://github.com/wu-clan) in [#842](https://github.com/fastapi-practices/fastapi_best_architecture/pull/842)
* Fix SQL script syntax error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#843](https://github.com/fastapi-practices/fastapi_best_architecture/pull/843)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#845](https://github.com/fastapi-practices/fastapi_best_architecture/pull/845)
* Update the ruff rules and format the code by [@wu-clan](https://github.com/wu-clan) in [#846](https://github.com/fastapi-practices/fastapi_best_architecture/pull/846)
* Bump fastapi and sqlalchemy to support python 3.14 by [@wu-clan](https://github.com/wu-clan) in [#847](https://github.com/fastapi-practices/fastapi_best_architecture/pull/847)
* Bump sqlalchemy-crud-plus from 0.12.0 to 0.12.1 by [@wu-clan](https://github.com/wu-clan) in [#848](https://github.com/fastapi-practices/fastapi_best_architecture/pull/848)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.2...v1.8.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.3]
<a id="v1.8.2"></a>
# [v1.8.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.2) - 2025-09-26
## What's Changed
* Update changelog for v1.8.1 by [@wu-clan](https://github.com/wu-clan) in [#804](https://github.com/fastapi-practices/fastapi_best_architecture/pull/804)
* Update the swagger docs version to dynamic by [@wu-clan](https://github.com/wu-clan) in [#805](https://github.com/fastapi-practices/fastapi_best_architecture/pull/805)
* Fix the subprocess ensurepip got stuck in Linux by [@byte-voyager](https://github.com/byte-voyager) in [#806](https://github.com/fastapi-practices/fastapi_best_architecture/pull/806)
* Update the offline location information parse by [@wu-clan](https://github.com/wu-clan) in [#807](https://github.com/fastapi-practices/fastapi_best_architecture/pull/807)
* Fix pgsql syntax error in code generation by [@byte-voyager](https://github.com/byte-voyager) in [#808](https://github.com/fastapi-practices/fastapi_best_architecture/pull/808)
* Fix summary default in opera log middleware by [@wu-clan](https://github.com/wu-clan) in [#809](https://github.com/fastapi-practices/fastapi_best_architecture/pull/809)
* Fix special character password support in alembic by [@MortyZhaoy](https://github.com/MortyZhaoy) in [#811](https://github.com/fastapi-practices/fastapi_best_architecture/pull/811)
* Update the startup progress information display by [@wu-clan](https://github.com/wu-clan) in [#812](https://github.com/fastapi-practices/fastapi_best_architecture/pull/812)
* Update the login captcha verify logic by [@wu-clan](https://github.com/wu-clan) in [#815](https://github.com/fastapi-practices/fastapi_best_architecture/pull/815)
* Fix the summary in opera log middleware by [@wu-clan](https://github.com/wu-clan) in [#816](https://github.com/fastapi-practices/fastapi_best_architecture/pull/816)
* Update the dict plugin table structure by [@wu-clan](https://github.com/wu-clan) in [#817](https://github.com/fastapi-practices/fastapi_best_architecture/pull/817)
* Add version number output to the startup CLI by [@wu-clan](https://github.com/wu-clan) in [#820](https://github.com/fastapi-practices/fastapi_best_architecture/pull/820)
* Update the user agent in opera log model by [@wu-clan](https://github.com/wu-clan) in [#831](https://github.com/fastapi-practices/fastapi_best_architecture/pull/831)
* Fix code generation template missing Decimal by [@wu-clan](https://github.com/wu-clan) in [#830](https://github.com/fastapi-practices/fastapi_best_architecture/pull/830)
* Fix the get model object method return logic by [@wu-clan](https://github.com/wu-clan) in [#832](https://github.com/fastapi-practices/fastapi_best_architecture/pull/832)
* Simplify the code generation CLI parameters by [@wu-clan](https://github.com/wu-clan) in [#837](https://github.com/fastapi-practices/fastapi_best_architecture/pull/837)
* Add the refresh token to demo mode exclusion by [@wu-clan](https://github.com/wu-clan) in [#834](https://github.com/fastapi-practices/fastapi_best_architecture/pull/834)
* Add extension parameters to paging data by [@wu-clan](https://github.com/wu-clan) in [#835](https://github.com/fastapi-practices/fastapi_best_architecture/pull/835)
* Fix the phone filter in the user list by [@wu-clan](https://github.com/wu-clan) in [#838](https://github.com/fastapi-practices/fastapi_best_architecture/pull/838)
* Update docker and deployment script comments by [@wu-clan](https://github.com/wu-clan) in [#839](https://github.com/fastapi-practices/fastapi_best_architecture/pull/839)
* Update the version number to 1.8.2 by [@wu-clan](https://github.com/wu-clan) in [#840](https://github.com/fastapi-practices/fastapi_best_architecture/pull/840)
## New Contributors
* [@byte-voyager](https://github.com/byte-voyager) made their first contribution in [#806](https://github.com/fastapi-practices/fastapi_best_architecture/pull/806)
* [@MortyZhaoy](https://github.com/MortyZhaoy) made their first contribution in [#811](https://github.com/fastapi-practices/fastapi_best_architecture/pull/811)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
## Contributors
<a href="https://github.com/MortyZhaoy"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FMortyZhaoy.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@MortyZhaoy"></a>
<a href="https://github.com/byte-voyager"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fbyte-voyager.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@byte-voyager"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.2]
<a id="v1.8.1"></a>
# [v1.8.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.1) - 2025-09-09
## What's Changed
* Update changelog for v1.8.0 by [@wu-clan](https://github.com/wu-clan) in [#772](https://github.com/fastapi-practices/fastapi_best_architecture/pull/772)
* Optimize the celery crontab expression validation by [@yanlingsishao](https://github.com/yanlingsishao) in [#773](https://github.com/fastapi-practices/fastapi_best_architecture/pull/773)
* Add bulk update interface for config plugin by [@wu-clan](https://github.com/wu-clan) in [#774](https://github.com/fastapi-practices/fastapi_best_architecture/pull/774)
* Optimize email sending config update logic by [@wu-clan](https://github.com/wu-clan) in [#775](https://github.com/fastapi-practices/fastapi_best_architecture/pull/775)
* Add test data SQL script for config plugin by [@wu-clan](https://github.com/wu-clan) in [#776](https://github.com/fastapi-practices/fastapi_best_architecture/pull/776)
* Update the env parameter value in env file by [@wu-clan](https://github.com/wu-clan) in [#777](https://github.com/fastapi-practices/fastapi_best_architecture/pull/777)
* Fix the docker compose env configuration file by [@wu-clan](https://github.com/wu-clan) in [#778](https://github.com/fastapi-practices/fastapi_best_architecture/pull/778)
* Fix redis client not close after plugin parse by [@wu-clan](https://github.com/wu-clan) in [#780](https://github.com/fastapi-practices/fastapi_best_architecture/pull/780)
* Fix the celery beat distributed lock timeout by [@wu-clan](https://github.com/wu-clan) in [#779](https://github.com/fastapi-practices/fastapi_best_architecture/pull/779)
* Fix the plugin model object detection logic by [@wuyao4](https://github.com/wuyao4) in [#782](https://github.com/fastapi-practices/fastapi_best_architecture/pull/782)
* Update the celery task result table creation logic by [@wu-clan](https://github.com/wu-clan) in [#783](https://github.com/fastapi-practices/fastapi_best_architecture/pull/783)
* Fix code generation template params and filename by [@wu-clan](https://github.com/wu-clan) in [#784](https://github.com/fastapi-practices/fastapi_best_architecture/pull/784)
* Fix CLI openapi url in production environment by [@siyue-wang](https://github.com/siyue-wang) in [#785](https://github.com/fastapi-practices/fastapi_best_architecture/pull/785)
* Update the model datetime column type to custom by [@wu-clan](https://github.com/wu-clan) in [#786](https://github.com/fastapi-practices/fastapi_best_architecture/pull/786)
* Update the opera log exception message record by [@wu-clan](https://github.com/wu-clan) in [#788](https://github.com/fastapi-practices/fastapi_best_architecture/pull/788)
* Update the handling of CORS 500 status code by [@wu-clan](https://github.com/wu-clan) in [#789](https://github.com/fastapi-practices/fastapi_best_architecture/pull/789)
* Add the Google OAuth2 login by [@wu-clan](https://github.com/wu-clan) in [#790](https://github.com/fastapi-practices/fastapi_best_architecture/pull/790)
* Fix the tzinfo comparison in the timezone type by [@IAseven](https://github.com/IAseven) in [#787](https://github.com/fastapi-practices/fastapi_best_architecture/pull/787)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#791](https://github.com/fastapi-practices/fastapi_best_architecture/pull/791)
* Update the notice model and pagination by [@wu-clan](https://github.com/wu-clan) in [#792](https://github.com/fastapi-practices/fastapi_best_architecture/pull/792)
* Add the dict type query all interface by [@wu-clan](https://github.com/wu-clan) in [#794](https://github.com/fastapi-practices/fastapi_best_architecture/pull/794)
* Fix sql script syntax error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#793](https://github.com/fastapi-practices/fastapi_best_architecture/pull/793)
* Fix the psycopg dependency naming by [@wu-clan](https://github.com/wu-clan) in [#795](https://github.com/fastapi-practices/fastapi_best_architecture/pull/795)
* Add schema for the tree data by [@wu-clan](https://github.com/wu-clan) in [#796](https://github.com/fastapi-practices/fastapi_best_architecture/pull/796)
* Add the code generator to the CLI by [@wu-clan](https://github.com/wu-clan) in [#798](https://github.com/fastapi-practices/fastapi_best_architecture/pull/798)
* Optimize the code generation interaction logic by [@wu-clan](https://github.com/wu-clan) in [#799](https://github.com/fastapi-practices/fastapi_best_architecture/pull/799)
* Fix console import in main module by [@wu-clan](https://github.com/wu-clan) in [#800](https://github.com/fastapi-practices/fastapi_best_architecture/pull/800)
* Fix the subprocess check call failed in windows by [@yzbf-lin](https://github.com/yzbf-lin) in [#802](https://github.com/fastapi-practices/fastapi_best_architecture/pull/802)
* Fix alembic migration file missing import by [@wu-clan](https://github.com/wu-clan) in [#803](https://github.com/fastapi-practices/fastapi_best_architecture/pull/803)
## New Contributors
* [@yanlingsishao](https://github.com/yanlingsishao) made their first contribution in [#773](https://github.com/fastapi-practices/fastapi_best_architecture/pull/773)
* [@wuyao4](https://github.com/wuyao4) made their first contribution in [#782](https://github.com/fastapi-practices/fastapi_best_architecture/pull/782)
* [@siyue-wang](https://github.com/siyue-wang) made their first contribution in [#785](https://github.com/fastapi-practices/fastapi_best_architecture/pull/785)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.0...v1.8.1
## Contributors
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
<a href="https://github.com/siyue-wang"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fsiyue-wang.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@siyue-wang"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
<a href="https://github.com/wuyao4"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwuyao4.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wuyao4"></a>
<a href="https://github.com/yanlingsishao"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyanlingsishao.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yanlingsishao"></a>
<a href="https://github.com/yzbf-lin"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyzbf-lin.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yzbf-lin"></a>
[Changes][v1.8.1]
<a id="v1.8.0"></a>
# [v1.8.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.0) - 2025-08-15
## What's Changed
* Update changelog for v1.7.0 by [@wu-clan](https://github.com/wu-clan) in [#729](https://github.com/fastapi-practices/fastapi_best_architecture/pull/729)
* Simplify task crontab expression validation by [@wu-clan](https://github.com/wu-clan) in [#733](https://github.com/fastapi-practices/fastapi_best_architecture/pull/733)
* Add distributed lock for scheduled task by [@wu-clan](https://github.com/wu-clan) in [#732](https://github.com/fastapi-practices/fastapi_best_architecture/pull/732)
* Update the default cache period for userinfo by [@wu-clan](https://github.com/wu-clan) in [#734](https://github.com/fastapi-practices/fastapi_best_architecture/pull/734)
* Fix celery CLI option to required by [@wu-clan](https://github.com/wu-clan) in [#737](https://github.com/fastapi-practices/fastapi_best_architecture/pull/737)
* Add auth whitelist regular expression config by [@wu-clan](https://github.com/wu-clan) in [#738](https://github.com/fastapi-practices/fastapi_best_architecture/pull/738)
* Fix the opera log field encryption by [@wu-clan](https://github.com/wu-clan) in [#739](https://github.com/fastapi-practices/fastapi_best_architecture/pull/739)
* Update the OAuth2 login password policy by [@wu-clan](https://github.com/wu-clan) in [#741](https://github.com/fastapi-practices/fastapi_best_architecture/pull/741)
* Add update support for user email and phone by [@wu-clan](https://github.com/wu-clan) in [#742](https://github.com/fastapi-practices/fastapi_best_architecture/pull/742)
* Fix the error trigger when model auto import by [@wu-clan](https://github.com/wu-clan) in [#743](https://github.com/fastapi-practices/fastapi_best_architecture/pull/743)
* Simplify the plugin status update logic by [@wu-clan](https://github.com/wu-clan) in [#744](https://github.com/fastapi-practices/fastapi_best_architecture/pull/744)
* Add some interfaces for user profiles by [@wu-clan](https://github.com/wu-clan) in [#745](https://github.com/fastapi-practices/fastapi_best_architecture/pull/745)
* Add schedule task demo that contains params by [@wu-clan](https://github.com/wu-clan) in [#746](https://github.com/fastapi-practices/fastapi_best_architecture/pull/746)
* Fix the kwargs params of schedule task by [@wu-clan](https://github.com/wu-clan) in [#747](https://github.com/fastapi-practices/fastapi_best_architecture/pull/747)
* Refactor code generation files and routes by [@wu-clan](https://github.com/wu-clan) in [#748](https://github.com/fastapi-practices/fastapi_best_architecture/pull/748)
* Refactor task routes and add control routes by [@wu-clan](https://github.com/wu-clan) in [#749](https://github.com/fastapi-practices/fastapi_best_architecture/pull/749)
* Fix message format in validation exception handler by [@wu-clan](https://github.com/wu-clan) in [#755](https://github.com/fastapi-practices/fastapi_best_architecture/pull/755)
* Update the opera log desensitization method by [@wu-clan](https://github.com/wu-clan) in [#756](https://github.com/fastapi-practices/fastapi_best_architecture/pull/756)
* Add business pagination in the code generator by [@wu-clan](https://github.com/wu-clan) in [#757](https://github.com/fastapi-practices/fastapi_best_architecture/pull/757)
* Optimize the data sort logic of tree nodes by [@wu-clan](https://github.com/wu-clan) in [#758](https://github.com/fastapi-practices/fastapi_best_architecture/pull/758)
* Update log output config and format by [@wu-clan](https://github.com/wu-clan) in [#759](https://github.com/fastapi-practices/fastapi_best_architecture/pull/759)
* Update the naming of table creation function by [@wu-clan](https://github.com/wu-clan) in [#760](https://github.com/fastapi-practices/fastapi_best_architecture/pull/760)
* Optimize the opera log storage logic through queue by [@IAseven](https://github.com/IAseven) in [#750](https://github.com/fastapi-practices/fastapi_best_architecture/pull/750)
* Optimize naming and preview in code generation by [@wu-clan](https://github.com/wu-clan) in [#764](https://github.com/fastapi-practices/fastapi_best_architecture/pull/764)
* Update the description for the run file by [@wu-clan](https://github.com/wu-clan) in [#766](https://github.com/fastapi-practices/fastapi_best_architecture/pull/766)
* Optimize the timezone datetime return encoder by [@wu-clan](https://github.com/wu-clan) in [#767](https://github.com/fastapi-practices/fastapi_best_architecture/pull/767)
* Update the content layout of the config file by [@wu-clan](https://github.com/wu-clan) in [#768](https://github.com/fastapi-practices/fastapi_best_architecture/pull/768)
* Add a standalone email sending plugin by [@wu-clan](https://github.com/wu-clan) in [#769](https://github.com/fastapi-practices/fastapi_best_architecture/pull/769)
* Add i18n support for response message by [@downdawn](https://github.com/downdawn) in [#753](https://github.com/fastapi-practices/fastapi_best_architecture/pull/753)
* Update the menu title in SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#770](https://github.com/fastapi-practices/fastapi_best_architecture/pull/770)
* Update the version number to 1.8.0 by [@wu-clan](https://github.com/wu-clan) in [#771](https://github.com/fastapi-practices/fastapi_best_architecture/pull/771)
## New Contributors
* [@IAseven](https://github.com/IAseven) made their first contribution in [#750](https://github.com/fastapi-practices/fastapi_best_architecture/pull/750)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.7.0...v1.8.0
## Contributors
<a href="https://github.com/IAseven"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FIAseven.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@IAseven"></a>
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.8.0]
<a id="v1.7.0"></a>
# [v1.7.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.7.0) - 2025-07-16
## What's Changed
* Update the changelog for v1.6.0 by [@wu-clan](https://github.com/wu-clan) in [#703](https://github.com/fastapi-practices/fastapi_best_architecture/pull/703)
* Update the CLI to be executed async by [@wu-clan](https://github.com/wu-clan) in [#704](https://github.com/fastapi-practices/fastapi_best_architecture/pull/704)
* Fix the code generation schema template by [@wu-clan](https://github.com/wu-clan) in [#706](https://github.com/fastapi-practices/fastapi_best_architecture/pull/706)
* Replace gunicorn deployment to granian by [@wu-clan](https://github.com/wu-clan) in [#705](https://github.com/fastapi-practices/fastapi_best_architecture/pull/705)
* Fix the code generation delete schema template by [@wu-clan](https://github.com/wu-clan) in [#708](https://github.com/fastapi-practices/fastapi_best_architecture/pull/708)
* Update the refresh token verify mechanism by [@wu-clan](https://github.com/wu-clan) in [#710](https://github.com/fastapi-practices/fastapi_best_architecture/pull/710)
* Update the reload excludes for CLI run by [@wu-clan](https://github.com/wu-clan) in [#709](https://github.com/fastapi-practices/fastapi_best_architecture/pull/709)
* Add CLI support for execute sql scripts by [@wu-clan](https://github.com/wu-clan) in [#711](https://github.com/fastapi-practices/fastapi_best_architecture/pull/711)
* Update the granian env to command params by [@wu-clan](https://github.com/wu-clan) in [#712](https://github.com/fastapi-practices/fastapi_best_architecture/pull/712)
* Update the middleware logging accuracy by [@wu-clan](https://github.com/wu-clan) in [#713](https://github.com/fastapi-practices/fastapi_best_architecture/pull/713)
* Update the log output default style by [@wu-clan](https://github.com/wu-clan) in [#714](https://github.com/fastapi-practices/fastapi_best_architecture/pull/714)
* Optimize the analysis of get plugins by [@wu-clan](https://github.com/wu-clan) in [#716](https://github.com/fastapi-practices/fastapi_best_architecture/pull/716)
* Simplify user permission database queries by [@wu-clan](https://github.com/wu-clan) in [#717](https://github.com/fastapi-practices/fastapi_best_architecture/pull/717)
* Update the CLI startup service mode by [@wu-clan](https://github.com/wu-clan) in [#718](https://github.com/fastapi-practices/fastapi_best_architecture/pull/718)
* Add support for celery dynamic tasks by [@wu-clan](https://github.com/wu-clan) in [#715](https://github.com/fastapi-practices/fastapi_best_architecture/pull/715)
* Fix the celery task scheduler query by [@wu-clan](https://github.com/wu-clan) in [#719](https://github.com/fastapi-practices/fastapi_best_architecture/pull/719)
* Update the celery task comment and name by [@wu-clan](https://github.com/wu-clan) in [#720](https://github.com/fastapi-practices/fastapi_best_architecture/pull/720)
* Optimize celery integrations and events by [@wu-clan](https://github.com/wu-clan) in [#721](https://github.com/fastapi-practices/fastapi_best_architecture/pull/721)
* Simplify celery task crontab config by [@wu-clan](https://github.com/wu-clan) in [#722](https://github.com/fastapi-practices/fastapi_best_architecture/pull/722)
* Delete the default value of schema enum data by [@wu-clan](https://github.com/wu-clan) in [#723](https://github.com/fastapi-practices/fastapi_best_architecture/pull/723)
* Fix the parsing of execution task params by [@wu-clan](https://github.com/wu-clan) in [#725](https://github.com/fastapi-practices/fastapi_best_architecture/pull/725)
* Bump granian from 2.4.0 to 2.4.2 by [@wu-clan](https://github.com/wu-clan) in [#727](https://github.com/fastapi-practices/fastapi_best_architecture/pull/727)
* Add CLI support for startup celery services by [@wu-clan](https://github.com/wu-clan) in [#724](https://github.com/fastapi-practices/fastapi_best_architecture/pull/724)
* Fix login and operation log clearing by [@wu-clan](https://github.com/wu-clan) in [#728](https://github.com/fastapi-practices/fastapi_best_architecture/pull/728)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.6.0...v1.7.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.7.0]
<a id="v1.6.0"></a>
# [v1.6.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.6.0) - 2025-06-30
## What's Changed
* Update changelog for v1.5.2 by [@wu-clan](https://github.com/wu-clan) in [#690](https://github.com/fastapi-practices/fastapi_best_architecture/pull/690)
* Optimize dict create and update logic by [@wu-clan](https://github.com/wu-clan) in [#691](https://github.com/fastapi-practices/fastapi_best_architecture/pull/691)
* Fix the OAuth2 redirect route names by [@wu-clan](https://github.com/wu-clan) in [#693](https://github.com/fastapi-practices/fastapi_best_architecture/pull/693)
* Update the SQL to adapt frontend plugin by [@wu-clan](https://github.com/wu-clan) in [#694](https://github.com/fastapi-practices/fastapi_best_architecture/pull/694)
* Update the extension plugin config by [@wu-clan](https://github.com/wu-clan) in [#695](https://github.com/fastapi-practices/fastapi_best_architecture/pull/695)
* Add the test user to SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#696](https://github.com/fastapi-practices/fastapi_best_architecture/pull/696)
* Add custom CLI for service startup by [@wu-clan](https://github.com/wu-clan) in [#697](https://github.com/fastapi-practices/fastapi_best_architecture/pull/697)
* Add CLI support for plugin install by [@wu-clan](https://github.com/wu-clan) in [#698](https://github.com/fastapi-practices/fastapi_best_architecture/pull/698)
* Update the help for CLI run worker by [@wu-clan](https://github.com/wu-clan) in [#699](https://github.com/fastapi-practices/fastapi_best_architecture/pull/699)
* Optimize the installation of plugin dependencies by [@wu-clan](https://github.com/wu-clan) in [#700](https://github.com/fastapi-practices/fastapi_best_architecture/pull/700)
* Update the Dockerfile to adapt latest code by [@wu-clan](https://github.com/wu-clan) in [#701](https://github.com/fastapi-practices/fastapi_best_architecture/pull/701)
* Update the version number to 1.6.0 by [@wu-clan](https://github.com/wu-clan) in [#702](https://github.com/fastapi-practices/fastapi_best_architecture/pull/702)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.2...v1.6.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.6.0]
<a id="v1.5.2"></a>
# [v1.5.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.5.2) - 2025-06-24
## What's Changed
* Update changelog for v1.5.1 by [@wu-clan](https://github.com/wu-clan) in [#671](https://github.com/fastapi-practices/fastapi_best_architecture/pull/671)
* Fix some error class import by [@wu-clan](https://github.com/wu-clan) in [#672](https://github.com/fastapi-practices/fastapi_best_architecture/pull/672)
* Optimize routes to better align with RESTful by [@wu-clan](https://github.com/wu-clan) in [#673](https://github.com/fastapi-practices/fastapi_best_architecture/pull/673)
* Add the snowflake ID sql script by [@wu-clan](https://github.com/wu-clan) in [#675](https://github.com/fastapi-practices/fastapi_best_architecture/pull/675)
* Optimize token detection and caching logic by [@wu-clan](https://github.com/wu-clan) in [#677](https://github.com/fastapi-practices/fastapi_best_architecture/pull/677)
* Update cache cleanup for logout interface by [@wu-clan](https://github.com/wu-clan) in [#678](https://github.com/fastapi-practices/fastapi_best_architecture/pull/678)
* Add dictionary type and datas queries by [@wu-clan](https://github.com/wu-clan) in [#679](https://github.com/fastapi-practices/fastapi_best_architecture/pull/679)
* Optimize api with semantic HTTP status codes by [@downdawn](https://github.com/downdawn) in [#681](https://github.com/fastapi-practices/fastapi_best_architecture/pull/681)
* Fix the code with outdated system config by [@wu-clan](https://github.com/wu-clan) in [#683](https://github.com/fastapi-practices/fastapi_best_architecture/pull/683)
* Update dict data label column config by [@wu-clan](https://github.com/wu-clan) in [#684](https://github.com/fastapi-practices/fastapi_best_architecture/pull/684)
* Update the init test data for SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#685](https://github.com/fastapi-practices/fastapi_best_architecture/pull/685)
* Simplify custom response status codes by [@wu-clan](https://github.com/wu-clan) in [#686](https://github.com/fastapi-practices/fastapi_best_architecture/pull/686)
* Optimize the zip plug-in file name parsing by [@wu-clan](https://github.com/wu-clan) in [#687](https://github.com/fastapi-practices/fastapi_best_architecture/pull/687)
* Add built-in plugin missing files by [@wu-clan](https://github.com/wu-clan) in [#688](https://github.com/fastapi-practices/fastapi_best_architecture/pull/688)
* Update the dict pagination query parameters by [@wu-clan](https://github.com/wu-clan) in [#689](https://github.com/fastapi-practices/fastapi_best_architecture/pull/689)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.1...v1.5.2
## Contributors
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.2]
<a id="v1.5.1"></a>
# [v1.5.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.5.1) - 2025-06-16
## What's Changed
* Update changelog for v1.5.0 by [@wu-clan](https://github.com/wu-clan) in [#664](https://github.com/fastapi-practices/fastapi_best_architecture/pull/664)
* Fix the sidebar menu type filtering by [@wu-clan](https://github.com/wu-clan) in [#667](https://github.com/fastapi-practices/fastapi_best_architecture/pull/667)
* Bump sqlalchemy crud plus version to 1.10.0 by [@wu-clan](https://github.com/wu-clan) in [#668](https://github.com/fastapi-practices/fastapi_best_architecture/pull/668)
* Fix the postgresql sql script syntax error by [@downdawn](https://github.com/downdawn) in [#669](https://github.com/fastapi-practices/fastapi_best_architecture/pull/669)
* Add Initial Snowflake ID Support by [@downdawn](https://github.com/downdawn) in [#670](https://github.com/fastapi-practices/fastapi_best_architecture/pull/670)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.0...v1.5.1
## Contributors
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.1]
<a id="v1.5.0"></a>
# [v1.5.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.5.0) - 2025-06-09
## What's Changed
* Update changelog for v1.4.3 by [@wu-clan](https://github.com/wu-clan) in [#651](https://github.com/fastapi-practices/fastapi_best_architecture/pull/651)
* Update OAuth2 callback interface return by [@wu-clan](https://github.com/wu-clan) in [#653](https://github.com/fastapi-practices/fastapi_best_architecture/pull/653)
* Update user email and phone operation logic by [@wu-clan](https://github.com/wu-clan) in [#654](https://github.com/fastapi-practices/fastapi_best_architecture/pull/654)
* Simplify OAuth2 model and optimize auth service by [@wu-clan](https://github.com/wu-clan) in [#655](https://github.com/fastapi-practices/fastapi_best_architecture/pull/655)
* Add OAuth2 user to auto bind a role by [@wu-clan](https://github.com/wu-clan) in [#656](https://github.com/fastapi-practices/fastapi_best_architecture/pull/656)
* Update data scope and rule to m2m by [@wu-clan](https://github.com/wu-clan) in [#657](https://github.com/fastapi-practices/fastapi_best_architecture/pull/657)
* Update code generate interface permission by [@wu-clan](https://github.com/wu-clan) in [#658](https://github.com/fastapi-practices/fastapi_best_architecture/pull/658)
* Update the plugin download interface permission by [@wu-clan](https://github.com/wu-clan) in [#659](https://github.com/fastapi-practices/fastapi_best_architecture/pull/659)
* Update auth failed default status code by [@wu-clan](https://github.com/wu-clan) in [#660](https://github.com/fastapi-practices/fastapi_best_architecture/pull/660)
* Update menu sort in init test sql by [@wu-clan](https://github.com/wu-clan) in [#661](https://github.com/fastapi-practices/fastapi_best_architecture/pull/661)
* Add data permission in init test sql by [@wu-clan](https://github.com/wu-clan) in [#662](https://github.com/fastapi-practices/fastapi_best_architecture/pull/662)
* Update the version to 1.5.0 by [@wu-clan](https://github.com/wu-clan) in [#663](https://github.com/fastapi-practices/fastapi_best_architecture/pull/663)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.3...v1.5.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.5.0]
<a id="v1.4.3"></a>
# [v1.4.3](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.3) - 2025-06-02
## What's Changed
* Update changelog for v1.4.2 by [@wu-clan](https://github.com/wu-clan) in [#639](https://github.com/fastapi-practices/fastapi_best_architecture/pull/639)
* Fix the role update business variables by [@wu-clan](https://github.com/wu-clan) in [#640](https://github.com/fastapi-practices/fastapi_best_architecture/pull/640)
* Fix the menu delete interface arg description by [@wu-clan](https://github.com/wu-clan) in [#641](https://github.com/fastapi-practices/fastapi_best_architecture/pull/641)
* Fix the filter of query all menus by [@wu-clan](https://github.com/wu-clan) in [#642](https://github.com/fastapi-practices/fastapi_best_architecture/pull/642)
* Refactor routes to better align with RESTful by [@wu-clan](https://github.com/wu-clan) in [#645](https://github.com/fastapi-practices/fastapi_best_architecture/pull/645)
* Update the server startup time to string by [@wu-clan](https://github.com/wu-clan) in [#646](https://github.com/fastapi-practices/fastapi_best_architecture/pull/646)
* Add get all data scope rules interface by [@wu-clan](https://github.com/wu-clan) in [#647](https://github.com/fastapi-practices/fastapi_best_architecture/pull/647)
* Add data permission condition for filter data by [@wu-clan](https://github.com/wu-clan) in [#648](https://github.com/fastapi-practices/fastapi_best_architecture/pull/648)
* Update default value for role filter scopes by [@wu-clan](https://github.com/wu-clan) in [#649](https://github.com/fastapi-practices/fastapi_best_architecture/pull/649)
* Fix data permission condition for filter data by [@wu-clan](https://github.com/wu-clan) in [#650](https://github.com/fastapi-practices/fastapi_best_architecture/pull/650)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.2...v1.4.3
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.3]
<a id="v1.4.2"></a>
# [v1.4.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.2) - 2025-05-29
## What's Changed
* Update changelog for v1.4.1 by [@wu-clan](https://github.com/wu-clan) in [#630](https://github.com/fastapi-practices/fastapi_best_architecture/pull/630)
* Update non-linked sidebar support by [@wu-clan](https://github.com/wu-clan) in [#633](https://github.com/fastapi-practices/fastapi_best_architecture/pull/633)
* Update the captcha invalidation error class by [@wu-clan](https://github.com/wu-clan) in [#634](https://github.com/fastapi-practices/fastapi_best_architecture/pull/634)
* Optimize role-related data processing performance by [@wu-clan](https://github.com/wu-clan) in [#635](https://github.com/fastapi-practices/fastapi_best_architecture/pull/635)
* Optimize install and build of plugin zip by [@wu-clan](https://github.com/wu-clan) in [#636](https://github.com/fastapi-practices/fastapi_best_architecture/pull/636)
* Fix auto-increment id for postgres init data by [@huyuwei1996](https://github.com/huyuwei1996) in [#632](https://github.com/fastapi-practices/fastapi_best_architecture/pull/632)
* Fix: prevent overwriting existing init files in code generator by [@lin-wu-1990](https://github.com/lin-wu-1990) in [#637](https://github.com/fastapi-practices/fastapi_best_architecture/pull/637)
* Simplify the user info update business by [@wu-clan](https://github.com/wu-clan) in [#638](https://github.com/fastapi-practices/fastapi_best_architecture/pull/638)
## New Contributors
* [@lin-wu-1990](https://github.com/lin-wu-1990) made their first contribution in [#637](https://github.com/fastapi-practices/fastapi_best_architecture/pull/637)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.1...v1.4.2
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.2]
<a id="v1.4.1"></a> <a id="v1.4.1"></a>
# [v1.4.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.1) - 2025-05-25 # [v1.4.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.4.1) - 2025-05-25
@@ -12,6 +622,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.1] [Changes][v1.4.1]
@@ -33,6 +647,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.4.0] [Changes][v1.4.0]
@@ -50,6 +668,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.3.0] [Changes][v1.3.0]
@@ -67,6 +689,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.2...v1.2.0
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.2.0] [Changes][v1.2.0]
@@ -84,6 +710,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.1...v1.1.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.2] [Changes][v1.1.2]
@@ -98,6 +728,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.1.0...v1.1.1
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.1] [Changes][v1.1.1]
@@ -117,6 +751,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.5...v1.1.0
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.1.0] [Changes][v1.1.0]
@@ -141,6 +780,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.4...v1.0.5
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.5] [Changes][v1.0.5]
@@ -169,6 +812,12 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.3...v1.0.4
## Contributors
<a href="https://github.com/PoetryL"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FPoetryL.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@PoetryL"></a>
<a href="https://github.com/ThankCat"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FThankCat.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@ThankCat"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.4] [Changes][v1.0.4]
@@ -187,6 +836,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.2...v1.0.3
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.3] [Changes][v1.0.3]
@@ -202,6 +856,10 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.1...v1.0.2
## Contributors
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.2] [Changes][v1.0.2]
@@ -221,6 +879,11 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
## Contributors
<a href="https://github.com/huyuwei1996"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fhuyuwei1996.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@huyuwei1996"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
[Changes][v1.0.1] [Changes][v1.0.1]
@@ -546,9 +1209,41 @@
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/commits/v1.0.0 **Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/commits/v1.0.0
## Contributors
<a href="https://github.com/Meepoljdx"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2FMeepoljdx.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@Meepoljdx"></a>
<a href="https://github.com/dependabot"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdependabot.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@dependabot"></a>
<a href="https://github.com/dividduang"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdividduang.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@dividduang"></a>
<a href="https://github.com/downdawn"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fdowndawn.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@downdawn"></a>
<a href="https://github.com/obrua"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fobrua.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@obrua"></a>
<a href="https://github.com/qhp13654398483"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fqhp13654398483.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@qhp13654398483"></a>
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
<a href="https://github.com/yshan2028"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fyshan2028.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@yshan2028"></a>
[Changes][v1.0.0] [Changes][v1.0.0]
[v1.12.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
[v1.11.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
[v1.11.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.0...v1.11.1
[v1.11.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.4...v1.11.0
[v1.10.4]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.3...v1.10.4
[v1.10.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.2...v1.10.3
[v1.10.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.1...v1.10.2
[v1.10.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.10.0...v1.10.1
[v1.10.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.9.0...v1.10.0
[v1.9.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.3...v1.9.0
[v1.8.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.2...v1.8.3
[v1.8.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
[v1.8.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.0...v1.8.1
[v1.8.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.7.0...v1.8.0
[v1.7.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.6.0...v1.7.0
[v1.6.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.2...v1.6.0
[v1.5.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.1...v1.5.2
[v1.5.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.0...v1.5.1
[v1.5.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.3...v1.5.0
[v1.4.3]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.2...v1.4.3
[v1.4.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.1...v1.4.2
[v1.4.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1 [v1.4.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.4.0...v1.4.1
[v1.4.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0 [v1.4.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.3.0...v1.4.0
[v1.3.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0 [v1.3.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.2.0...v1.3.0
@@ -563,4 +1258,4 @@
[v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1 [v1.0.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.0.0...v1.0.1
[v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0 [v1.0.0]: https://github.com/fastapi-practices/fastapi_best_architecture/tree/v1.0.0
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.0 --> <!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
+62
View File
@@ -0,0 +1,62 @@
## 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 sync
```
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 `prek`
```shell
prek 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
- `migrate.sh`: Perform automatic database migration
- `scripts/format.sh`: Perform ruff format check
- `scripts/lint.sh`: Perform prek formatting
- `scripts/export.sh`: Execute uv export dependency package
+35 -26
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
@@ -10,6 +10,10 @@ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debi
&& apt-get install -y --no-install-recommends gcc python3-dev \ && apt-get install -y --no-install-recommends gcc python3-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY . /fba
WORKDIR /fba
# Configure uv environment # Configure uv environment
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_NO_CACHE=1 \ UV_NO_CACHE=1 \
@@ -18,58 +22,63 @@ 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 \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-default-groups --group server uv sync --frozen --no-default-groups --group server
# === Runtime base server image === # === Runtime base server image ===
FROM python:3.10-slim AS base_server FROM python:3.10-slim AS base_server
SHELL ["/bin/bash", "-c"]
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 supervisor \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY . /fba COPY --from=builder /fba /fba
COPY --from=builder /usr/local /usr/local COPY --from=builder /usr/local /usr/local
# Install plugin dependencies COPY deploy/backend/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /fba
ENV PYTHONPATH=/fba WORKDIR /fba/backend
RUN python3 backend/scripts/init_plugin.py
# === FastAPI server image === # === FastAPI server image ===
FROM base_server AS fastapi_server FROM base_server AS fba_server
WORKDIR /fba COPY deploy/backend/supervisor/fba_server.conf /etc/supervisor/conf.d/
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf RUN mkdir -p /var/log/fba
COPY deploy/backend/fastapi_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fastapi_server
EXPOSE 8001 EXPOSE 8001
CMD ["uvicorn", "backend.main:app", "--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
WORKDIR /fba/backend/ COPY deploy/backend/supervisor/fba_celery_worker.conf /etc/supervisor/conf.d/
COPY deploy/backend/supervisord.conf /etc/supervisor/supervisord.conf RUN mkdir -p /var/log/fba
COPY deploy/backend/celery.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/celery CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
RUN chmod +x celery-start.sh # === 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}
+15 -12
View File
@@ -1,10 +1,10 @@
# Env: dev、pro # 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'
@@ -15,18 +15,21 @@ REDIS_DATABASE=0
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log # Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b' OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# App Admin # [ App ] task
# OAuth2
OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# App Task
# Celery # Celery
CELERY_BROKER_REDIS_DATABASE=1 CELERY_BROKER_REDIS_DATABASE=1
CELERY_BACKEND_REDIS_DATABASE=2
# Rabbitmq # Rabbitmq
CELERY_RABBITMQ_HOST='127.0.0.1' CELERY_RABBITMQ_HOST='127.0.0.1'
CELERY_RABBITMQ_PORT=5672 CELERY_RABBITMQ_PORT=5672
CELERY_RABBITMQ_USERNAME='guest' CELERY_RABBITMQ_USERNAME='guest'
CELERY_RABBITMQ_PASSWORD='guest' CELERY_RABBITMQ_PASSWORD='guest'
# [ Plugin ] oauth2
OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# [ Plugin ] email
EMAIL_USERNAME=''
EMAIL_PASSWORD=''
-55
View File
@@ -1,55 +0,0 @@
line-length = 120
cache-dir = ".ruff_cache"
target-version = "py310"
unsafe-fixes = true
show-fixes = true
[lint]
select = [
"E",
"F",
"I",
"TC",
# W
"W505",
# PT
"PT018",
# SIM
"SIM101",
"SIM114",
# PGH
"PGH004",
# PL
"PLE1142",
# RUF
"RUF100",
# UP
"UP007"
]
preview = true
ignore = ["FURB101"]
[lint.flake8-pytest-style]
mark-parentheses = false
parametrize-names-type = "list"
parametrize-values-row-type = "list"
parametrize-values-type = "tuple"
[lint.flake8-unused-arguments]
ignore-variadic-names = true
[lint.isort]
lines-between-types = 1
order-by-type = true
[lint.per-file-ignores]
"**/api/v1/*.py" = ["TC"]
"**/model/*.py" = ["TC003"]
"**/model/__init__.py" = ["F401"]
"**/tests/*.py" = ["E402"]
[format]
preview = true
quote-style = "single"
docstring-code-format = true
skip-magic-trailing-comma = false
-65
View File
@@ -16,68 +16,3 @@
```shell ```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 and create database tables
- `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
+17 -2
View File
@@ -1,2 +1,17 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from backend.utils.import_parse 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
__version__ = '1.12.0'
+7 -22
View File
@@ -1,9 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ruff: noqa: F403, F401, I001, RUF100
import asyncio import asyncio
import os import os
import sys
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context from alembic import context
@@ -11,24 +8,9 @@ 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
sys.path.append('../')
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.database.db import SQLALCHEMY_DATABASE_URL from backend.database.db import SQLALCHEMY_DATABASE_URL
from backend.plugin.tools import get_plugin_models
# import your new model here
from backend.app.admin.model import * # noqa: F401
from backend.plugin.code_generator.model import * # noqa: F401
# import plugin model
for cls in get_plugin_models():
class_name = cls.__name__
if class_name in globals():
print(f'\nWarning: Class "{class_name}" already exists in global namespace.')
else:
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)
@@ -47,10 +29,13 @@ if alembic_config.config_file_name is not None:
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('sqlalchemy.url', SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False)) alembic_config.set_main_option(
'sqlalchemy.url',
SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'),
)
def run_migrations_offline(): def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. """Run migrations in 'offline' mode.
This configures the context with just a URL This configures the context with just a URL
@@ -79,7 +64,7 @@ def run_migrations_offline():
def do_run_migrations(connection: Connection) -> None: def do_run_migrations(connection: Connection) -> None:
# 当迁移无变化时,不生成迁移记录 # 当迁移无变化时,不生成迁移记录
def process_revision_directives(context, revision, directives): def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001
if alembic_config.cmd_opts.autogenerate: if alembic_config.cmd_opts.autogenerate:
script = directives[0] script = directives[0]
if script.upgrade_ops.is_empty(): if script.upgrade_ops.is_empty():
+1
View File
@@ -7,6 +7,7 @@ Create Date: ${create_date}
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import backend.common.model
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router from backend.app.admin.api.v1.auth import router as auth_router
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.auth.auth import router as auth_router from backend.app.admin.api.v1.auth.auth import router as auth_router
@@ -8,4 +6,4 @@ from backend.app.admin.api.v1.auth.captcha import router as captcha_router
router = APIRouter(prefix='/auth') router = APIRouter(prefix='/auth')
router.include_router(auth_router, tags=['授权']) router.include_router(auth_router, tags=['授权'])
router.include_router(captcha_router, prefix='/captcha', tags=['验证码']) router.include_router(captcha_router, tags=['验证码'])
+22 -11
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
@@ -11,13 +9,17 @@ from backend.app.admin.schema.token import GetLoginToken, GetNewToken, GetSwagge
from backend.app.admin.schema.user import AuthLoginParam from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.auth_service import auth_service 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.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证') @router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
async def swagger_login(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken: async def login_swagger(
token, user = await auth_service.swagger_login(obj=obj) db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(db=db, obj=obj)
return GetSwaggerToken(access_token=token, user=user) # type: ignore return GetSwaggerToken(access_token=token, user=user) # type: ignore
@@ -27,20 +29,29 @@ async def swagger_login(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetS
description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman', description='json 格式登录, 仅支持在第三方api工具调试, 例如: postman',
dependencies=[Depends(RateLimiter(times=5, minutes=1))], dependencies=[Depends(RateLimiter(times=5, minutes=1))],
) )
async def user_login( async def login(
request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks db: CurrentSessionTransaction,
response: Response,
obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> ResponseSchemaModel[GetLoginToken]: ) -> ResponseSchemaModel[GetLoginToken]:
data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks) data = await auth_service.login(db=db, response=response, obj=obj, background_tasks=background_tasks)
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/token/new', summary='创建新 token') @router.get('/codes', summary='获取所有授权码', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def create_new_token(request: Request) -> ResponseSchemaModel[GetNewToken]: async def get_codes(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[str]]:
data = await auth_service.new_token(request=request) codes = await auth_service.get_codes(db=db, request=request)
return response_base.success(data=codes)
@router.post('/refresh', summary='刷新 token')
async def refresh_token(db: CurrentSession, request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(db=db, request=request)
return response_base.success(data=data) return response_base.success(data=data)
@router.post('/logout', summary='用户登出') @router.post('/logout', summary='用户登出')
async def user_logout(request: Request, response: Response) -> ResponseModel: async def logout(request: Request, response: Response) -> ResponseModel:
await auth_service.logout(request=request, response=response) await auth_service.logout(request=request, response=response)
return response_base.success() return response_base.success()
+18 -14
View File
@@ -1,34 +1,38 @@
#!/usr/bin/env python3 import uuid
# -*- coding: utf-8 -*-
from fast_captcha import img_captcha from fast_captcha import img_captcha
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends
from fastapi_limiter.depends import RateLimiter from fastapi_limiter.depends import RateLimiter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.admin.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
router = APIRouter() router = APIRouter()
@router.get( @router.get(
'', '/captcha',
summary='获取登录验证码', summary='获取登录验证码',
dependencies=[Depends(RateLimiter(times=5, seconds=10))], dependencies=[Depends(RateLimiter(times=5, seconds=10))],
) )
async def get_captcha(request: Request) -> 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)
ip = request.state.ip
await redis_client.set( await redis_client.set(
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}', 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(image_type=img_type, image=img)
return response_base.success(data=data) return response_base.success(data=data)
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.log.login_log import router as login_log from backend.app.admin.api.v1.log.login_log import router as login_log
+12 -16
View File
@@ -1,17 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.login_log import GetLoginLogDetail from backend.app.admin.schema.login_log import DeleteLoginLogParam, GetLoginLogDetail
from backend.app.admin.service.login_log_service import login_log_service from backend.app.admin.service.login_log_service import login_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData
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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@@ -24,14 +22,14 @@ router = APIRouter()
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_login_logs( async def get_login_logs_paginated(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]: ) -> ResponseSchemaModel[PageData[GetLoginLogDetail]]:
log_select = await login_log_service.get_select(username=username, status=status, ip=ip) page_data = await login_log_service.get_list(db=db, username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -43,8 +41,8 @@ async def get_pagination_login_logs(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_login_log(pk: Annotated[list[int], Query(description='登录日志 ID 列表')]) -> ResponseModel: async def delete_login_logs(db: CurrentSessionTransaction, obj: DeleteLoginLogParam) -> ResponseModel:
count = await login_log_service.delete(pk=pk) count = await login_log_service.delete(db=db, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -54,12 +52,10 @@ async def delete_login_log(pk: Annotated[list[int], Query(description='登录日
'/all', '/all',
summary='清空登录日志', summary='清空登录日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:login:empty')), Depends(RequestPermission('log:login:clear')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_all_login_logs() -> ResponseModel: async def delete_all_login_logs(db: CurrentSessionTransaction) -> ResponseModel:
count = await login_log_service.delete_all() await login_log_service.delete_all(db=db)
if count > 0: return response_base.success()
return response_base.success()
return response_base.fail()
+12 -16
View File
@@ -1,17 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from backend.app.admin.schema.opera_log import GetOperaLogDetail from backend.app.admin.schema.opera_log import DeleteOperaLogParam, GetOperaLogDetail
from backend.app.admin.service.opera_log_service import opera_log_service from backend.app.admin.service.opera_log_service import opera_log_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData
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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@@ -24,14 +22,14 @@ router = APIRouter()
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_opera_logs( async def get_opera_logs_paginated(
db: CurrentSession, db: CurrentSession,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
ip: Annotated[str | None, Query(description='IP 地址')] = None, ip: Annotated[str | None, Query(description='IP 地址')] = None,
) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]: ) -> ResponseSchemaModel[PageData[GetOperaLogDetail]]:
log_select = await opera_log_service.get_select(username=username, status=status, ip=ip) page_data = await opera_log_service.get_list(db=db, username=username, status=status, ip=ip)
page_data = await paging_data(db, log_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -43,8 +41,8 @@ async def get_pagination_opera_logs(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日志 ID 列表')]) -> ResponseModel: async def delete_opera_logs(db: CurrentSessionTransaction, obj: DeleteOperaLogParam) -> ResponseModel:
count = await opera_log_service.delete(pk=pk) count = await opera_log_service.delete(db=db, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -54,12 +52,10 @@ async def delete_opera_log(pk: Annotated[list[int], Query(description='操作日
'/all', '/all',
summary='清空操作日志', summary='清空操作日志',
dependencies=[ dependencies=[
Depends(RequestPermission('log:opera:empty')), Depends(RequestPermission('log:opera:clear')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_all_opera_logs() -> ResponseModel: async def delete_all_opera_logs(db: CurrentSessionTransaction) -> ResponseModel:
count = await opera_log_service.delete_all() await opera_log_service.delete_all(db=db)
if count > 0: return response_base.success()
return response_base.success()
return response_base.fail()
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.monitor.online import router as token_router from backend.app.admin.api.v1.monitor.online import router as token_router
@@ -10,4 +8,4 @@ router = APIRouter(prefix='/monitors')
router.include_router(redis_router, prefix='/redis', tags=['redis监控']) router.include_router(redis_router, prefix='/redis', tags=['redis监控'])
router.include_router(server_router, prefix='/server', tags=['服务器监控']) router.include_router(server_router, prefix='/server', tags=['服务器监控'])
router.include_router(token_router, prefix='/online', tags=['在线用户']) router.include_router(token_router, prefix='/sessions', tags=['会话监控'])
+13 -21
View File
@@ -1,17 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json import json
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request 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, jwt_decode, revoke_token, superuser_verify from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser, jwt_decode, revoke_token
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
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
@@ -19,10 +15,10 @@ router = APIRouter()
@router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth]) @router.get('', summary='获取在线用户', dependencies=[DependsJwtAuth])
async def get_online( 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] = []
@@ -37,16 +33,17 @@ async def get_online(
'browser': extra_info.get('browser', '未知'), 'browser': extra_info.get('browser', '未知'),
'device': extra_info.get('device', '未知'), 'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'), 'last_login_time': extra_info.get('last_login_time', '未知'),
} },
) ),
) )
for key in token_keys: for key in token_keys:
token = await redis_client.get(key) token = await redis_client.get(key)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id
session_uuid = token_payload.session_uuid session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail( token_detail = GetTokenDetail(
id=token_payload.id, id=user_id,
session_uuid=session_uuid, session_uuid=session_uuid,
username='未知', username='未知',
nickname='未知', nickname='未知',
@@ -58,7 +55,7 @@ async def get_online(
last_login_time='未知', last_login_time='未知',
expire_time=token_payload.expire_time, expire_time=token_payload.expire_time,
) )
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}') extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if extra_info: if extra_info:
extra_info = json.loads(extra_info) extra_info = json.loads(extra_info)
# 排除 swagger 登录生成的 token # 排除 swagger 登录生成的 token
@@ -75,17 +72,12 @@ async def get_online(
@router.delete( @router.delete(
'/{pk}', '/{pk}',
summary='下线', summary='强制下线',
dependencies=[ dependencies=[DependsSuperUser],
Depends(RequestPermission('sys:token:kick')),
DependsRBAC,
],
) )
async def kick_out( async def delete_session(
request: Request,
pk: Annotated[int, Path(description='用户 ID')], pk: Annotated[int, Path(description='用户 ID')],
session_uuid: Annotated[str, Query(description='会话 UUID')], session_uuid: Annotated[str, Query(description='会话 UUID')],
) -> ResponseModel: ) -> ResponseModel:
superuser_verify(request) await revoke_token(pk, session_uuid)
await revoke_token(str(pk), session_uuid)
return response_base.success() return response_base.success()
+2 -12
View File
@@ -1,23 +1,13 @@
#!/usr/bin/env python3 from fastapi import APIRouter
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from backend.common.response.response_schema import ResponseModel, response_base from backend.common.response.response_schema import ResponseModel, 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.utils.redis_info import redis_info from backend.utils.redis_info import redis_info
router = APIRouter() router = APIRouter()
@router.get( @router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
'',
summary='redis 监控',
dependencies=[
Depends(RequestPermission('sys:monitor:redis')),
DependsJwtAuth,
],
)
async def get_redis_info() -> ResponseModel: async def get_redis_info() -> ResponseModel:
data = { data = {
'info': await redis_info.get_info(), 'info': await redis_info.get_info(),
+2 -12
View File
@@ -1,24 +1,14 @@
#!/usr/bin/env python3 from fastapi import APIRouter
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
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.common.response.response_schema import ResponseModel, 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.utils.server_info import server_info from backend.utils.server_info import server_info
router = APIRouter() router = APIRouter()
@router.get( @router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
'',
summary='server 监控',
dependencies=[
Depends(RequestPermission('sys:monitor:server')),
DependsJwtAuth,
],
)
async def get_server_info() -> ResponseModel: async def get_server_info() -> ResponseModel:
data = { data = {
# 扔到线程池,避免阻塞 # 扔到线程池,避免阻塞
+3 -5
View File
@@ -1,14 +1,12 @@
# !/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter 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.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
from backend.app.admin.api.v1.sys.upload import router as upload_router
from backend.app.admin.api.v1.sys.user import router as user_router from backend.app.admin.api.v1.sys.user import router as user_router
router = APIRouter(prefix='/sys') router = APIRouter(prefix='/sys')
@@ -19,5 +17,5 @@ router.include_router(role_router, prefix='/roles', tags=['系统角色'])
router.include_router(user_router, prefix='/users', tags=['系统用户']) router.include_router(user_router, prefix='/users', tags=['系统用户'])
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则']) router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据规则'])
router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围']) router.include_router(data_scope_router, prefix='/data-scopes', tags=['系统数据范围'])
router.include_router(upload_router, prefix='/upload', tags=['系统上传']) router.include_router(file_router, prefix='/files', tags=['系统文件'])
router.include_router(plugin_router, prefix='/plugin', tags=['系统插件']) router.include_router(plugin_router, prefix='/plugins', tags=['系统插件'])
+20 -18
View File
@@ -1,22 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_rule import ( from backend.app.admin.schema.data_rule import (
CreateDataRuleParam, CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail, GetDataRuleColumnDetail,
GetDataRuleDetail, GetDataRuleDetail,
UpdateDataRuleParam, UpdateDataRuleParam,
) )
from backend.app.admin.service.data_rule_service import data_rule_service from backend.app.admin.service.data_rule_service import data_rule_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@@ -27,7 +26,7 @@ async def get_data_rule_models() -> ResponseSchemaModel[list[str]]:
return response_base.success(data=models) return response_base.success(data=models)
@router.get('/model/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth]) @router.get('/models/{model}/columns', summary='获取数据规则可用模型列', dependencies=[DependsJwtAuth])
async def get_data_rule_model_columns( async def get_data_rule_model_columns(
model: Annotated[str, Path(description='模型名称')], model: Annotated[str, Path(description='模型名称')],
) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]: ) -> ResponseSchemaModel[list[GetDataRuleColumnDetail]]:
@@ -36,16 +35,17 @@ async def get_data_rule_model_columns(
@router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有数据规则', dependencies=[DependsJwtAuth])
async def get_all_data_rules() -> ResponseSchemaModel[list[GetDataRuleDetail]]: async def get_all_data_rules(db: CurrentSession) -> ResponseSchemaModel[list[GetDataRuleDetail]]:
data = await data_rule_service.get_all() data = await data_rule_service.get_all(db=db)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取数据规则详情', dependencies=[DependsJwtAuth])
async def get_data_rule( async def get_data_rule(
db: CurrentSession,
pk: Annotated[int, Path(description='数据规则 ID')], pk: Annotated[int, Path(description='数据规则 ID')],
) -> ResponseSchemaModel[GetDataRuleDetail]: ) -> ResponseSchemaModel[GetDataRuleDetail]:
data = await data_rule_service.get(pk=pk) data = await data_rule_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -57,11 +57,11 @@ async def get_data_rule(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_rules( async def get_data_rules_paginated(
db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None db: CurrentSession,
name: Annotated[str | None, Query(description='规则名称')] = None,
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]: ) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
data_rule_select = await data_rule_service.get_select(name=name) page_data = await data_rule_service.get_list(db=db, name=name)
page_data = await paging_data(db, data_rule_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -73,8 +73,8 @@ async def get_pagination_data_rules(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel: async def create_data_rule(db: CurrentSessionTransaction, obj: CreateDataRuleParam) -> ResponseModel:
await data_rule_service.create(obj=obj) await data_rule_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -87,9 +87,11 @@ async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
], ],
) )
async def update_data_rule( async def update_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='数据规则 ID')],
obj: UpdateDataRuleParam,
) -> ResponseModel: ) -> ResponseModel:
count = await data_rule_service.update(pk=pk, obj=obj) count = await data_rule_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -103,8 +105,8 @@ async def update_data_rule(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_data_rule(pk: Annotated[list[int], Query(description='数据规则 ID 列表')]) -> ResponseModel: async def delete_data_rules(db: CurrentSessionTransaction, obj: DeleteDataRuleParam) -> ResponseModel:
count = await data_rule_service.delete(pk=pk) count = await data_rule_service.delete(db=db, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+28 -18
View File
@@ -1,40 +1,47 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.data_scope import ( from backend.app.admin.schema.data_scope import (
CreateDataScopeParam, CreateDataScopeParam,
DeleteDataScopeParam,
GetDataScopeDetail, GetDataScopeDetail,
GetDataScopeWithRelationDetail, GetDataScopeWithRelationDetail,
UpdateDataScopeParam, UpdateDataScopeParam,
UpdateDataScopeRuleParam, UpdateDataScopeRuleParam,
) )
from backend.app.admin.service.data_scope_service import data_scope_service from backend.app.admin.service.data_scope_service import data_scope_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有数据范围', dependencies=[DependsJwtAuth])
async def get_all_data_scope(db: CurrentSession) -> ResponseSchemaModel[list[GetDataScopeDetail]]:
data = await data_scope_service.get_all(db=db)
return response_base.success(data=data)
@router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取数据范围详情', dependencies=[DependsJwtAuth])
async def get_data_scope( async def get_data_scope(
db: CurrentSession,
pk: Annotated[int, Path(description='数据范围 ID')], pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeDetail]: ) -> ResponseSchemaModel[GetDataScopeDetail]:
data = await data_scope_service.get(pk=pk) data = await data_scope_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth]) @router.get('/{pk}/rules', summary='获取数据范围所有规则', dependencies=[DependsJwtAuth])
async def get_data_scope_rules( async def get_data_scope_rules(
db: CurrentSession,
pk: Annotated[int, Path(description='数据范围 ID')], pk: Annotated[int, Path(description='数据范围 ID')],
) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]: ) -> ResponseSchemaModel[GetDataScopeWithRelationDetail]:
data = await data_scope_service.get_rules(pk=pk) data = await data_scope_service.get_rules(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -46,13 +53,12 @@ async def get_data_scope_rules(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_data_scopes( async def get_data_scopes_paginated(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query(description='范围名称')] = None, name: Annotated[str | None, Query(description='范围名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]: ) -> ResponseSchemaModel[PageData[GetDataScopeDetail]]:
data_scope_select = await data_scope_service.get_select(name=name, status=status) page_data = await data_scope_service.get_list(db=db, name=name, status=status)
page_data = await paging_data(db, data_scope_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -64,8 +70,8 @@ async def get_pagination_data_scopes(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel: async def create_data_scope(db: CurrentSessionTransaction, obj: CreateDataScopeParam) -> ResponseModel:
await data_scope_service.create(obj=obj) await data_scope_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -78,9 +84,11 @@ async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
], ],
) )
async def update_data_scope( async def update_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='数据范围 ID')],
obj: UpdateDataScopeParam,
) -> ResponseModel: ) -> ResponseModel:
count = await data_scope_service.update(pk=pk, obj=obj) count = await data_scope_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -95,9 +103,11 @@ async def update_data_scope(
], ],
) )
async def update_data_scope_rules( async def update_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam db: CurrentSessionTransaction,
): pk: Annotated[int, Path(description='数据范围 ID')],
count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids) rule_ids: UpdateDataScopeRuleParam,
) -> ResponseModel:
count = await data_scope_service.update_data_scope_rule(db=db, pk=pk, rule_ids=rule_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -111,8 +121,8 @@ async def update_data_scope_rules(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_data_scope(pk: Annotated[list[int], Query(description='数据范围 ID 列表')]) -> ResponseModel: async def delete_data_scopes(db: CurrentSessionTransaction, obj: DeleteDataScopeParam) -> ResponseModel:
count = await data_scope_service.delete(pk=pk) count = await data_scope_service.delete(db=db, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+27 -19
View File
@@ -1,34 +1,40 @@
#!/usr/bin/env python3 from typing import Annotated
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import ColumnElement
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam from backend.app.admin.model import Dept
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service from backend.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
router = APIRouter() router = APIRouter()
@router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取部门详情', dependencies=[DependsJwtAuth])
async def get_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseSchemaModel[GetDeptDetail]: async def get_dept(
data = await dept_service.get(pk=pk) db: CurrentSession, pk: Annotated[int, Path(description='部门 ID')]
) -> ResponseSchemaModel[GetDeptDetail]:
data = await dept_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有部门展示', dependencies=[DependsJwtAuth]) @router.get('', summary='获取部门', dependencies=[DependsJwtAuth])
async def get_all_depts( async def get_dept_tree(
request: Request, db: CurrentSession,
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[dict[str, Any]]]: ) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_dept_tree(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)
@@ -40,8 +46,8 @@ async def get_all_depts(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_dept(obj: CreateDeptParam) -> ResponseModel: async def create_dept(db: CurrentSessionTransaction, obj: CreateDeptParam) -> ResponseModel:
await dept_service.create(obj=obj) await dept_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -53,8 +59,10 @@ async def create_dept(obj: CreateDeptParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam) -> ResponseModel: async def update_dept(
count = await dept_service.update(pk=pk, obj=obj) db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')], obj: UpdateDeptParam
) -> ResponseModel:
count = await dept_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -68,8 +76,8 @@ async def update_dept(pk: Annotated[int, Path(description='部门 ID')], obj: Up
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_dept(pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel: async def delete_dept(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='部门 ID')]) -> ResponseModel:
count = await dept_service.delete(pk=pk) count = await dept_service.delete(db=db, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+25
View File
@@ -0,0 +1,25 @@
from typing import Annotated
from fastapi import APIRouter, Depends, File, UploadFile
from backend.common.dataclasses import UploadUrl
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.utils.file_ops import upload_file, upload_file_verify
router = APIRouter()
@router.post(
'/upload',
summary='本地文件上传',
dependencies=[
Depends(RequestPermission('sys:file:upload')),
DependsRBAC,
],
)
async def upload_files(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
upload_file_verify(file)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
+22 -18
View File
@@ -1,37 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
from backend.app.admin.service.menu_service import menu_service from backend.app.admin.service.menu_service import menu_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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben5', dependencies=[DependsJwtAuth]) @router.get('/sidebar', summary='获取用户菜单侧边栏', description='适配 vben admin v5', dependencies=[DependsJwtAuth])
async def get_user_sidebar(request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]: async def get_user_sidebar(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[dict[str, Any] | None]]:
menu = await menu_service.get_sidebar(request=request) menu = await menu_service.get_sidebar(db=db, request=request)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取菜单详情', dependencies=[DependsJwtAuth])
async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseSchemaModel[GetMenuDetail]: async def get_menu(
data = await menu_service.get(pk=pk) db: CurrentSession, pk: Annotated[int, Path(description='菜单 ID')]
) -> ResponseSchemaModel[GetMenuDetail]:
data = await menu_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('', summary='获取所有菜单展示', dependencies=[DependsJwtAuth]) @router.get('', summary='获取菜单', dependencies=[DependsJwtAuth])
async def get_all_menus( async def get_menu_tree(
db: CurrentSession,
title: Annotated[str | None, Query(description='菜单标题')] = None, title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None, status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[GetMenuTree]]:
menu = await menu_service.get_menu_tree(title=title, status=status) menu = await menu_service.get_tree(db=db, title=title, status=status)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -43,8 +45,8 @@ async def get_all_menus(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_menu(obj: CreateMenuParam) -> ResponseModel: async def create_menu(db: CurrentSessionTransaction, obj: CreateMenuParam) -> ResponseModel:
await menu_service.create(obj=obj) await menu_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -56,8 +58,10 @@ async def create_menu(obj: CreateMenuParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam) -> ResponseModel: async def update_menu(
count = await menu_service.update(pk=pk, obj=obj) db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')], obj: UpdateMenuParam
) -> ResponseModel:
count = await menu_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -71,8 +75,8 @@ async def update_menu(pk: Annotated[int, Path(description='菜单 ID')], obj: Up
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_menu(pk: Annotated[int, Path(description='菜单 ID 列表')]) -> ResponseModel: async def delete_menu(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='菜单 ID')]) -> ResponseModel:
count = await menu_service.delete(pk=pk) count = await menu_service.delete(db=db, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
+29 -40
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Path, UploadFile from fastapi import APIRouter, Depends, File, Path, UploadFile
@@ -7,7 +5,8 @@ from fastapi.params import Query
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from backend.app.admin.service.plugin_service import plugin_service from backend.app.admin.service.plugin_service import plugin_service
from backend.common.response.response_code import CustomResponseCode from backend.common.enums import PluginType
from backend.common.response.response_code import CustomResponse
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.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 RequestPermission
@@ -22,42 +21,37 @@ async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
return response_base.success(data=plugins) return response_base.success(data=plugins)
@router.get('/changed', summary='插件状态是否变更', dependencies=[DependsJwtAuth]) @router.get('/changed', summary='是否存在插件变更', dependencies=[DependsJwtAuth])
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))
@router.post( @router.post(
'/install/zip', '',
summary='安装 zip 插件', summary='安装插件',
description='使用插件 zip 压缩包进行安装', description='使用插件 zip 压缩包或 git 仓库地址进行安装',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:install')), Depends(RequestPermission('sys:plugin:install')),
DependsRBAC, DependsRBAC,
], ],
) )
async def install_zip_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: async def install_plugin(
await plugin_service.install_zip(file=file) type: Annotated[PluginType, Query(description='插件类型')],
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS) file: Annotated[UploadFile | None, File()] = None,
repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None,
) -> ResponseModel:
@router.post( plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
'/install/git', return response_base.success(
summary='安装 git 插件', res=CustomResponse(
description='使用插件 git 仓库地址进行安装,不限制平台;如果需要凭证,需在 git 仓库地址中添加凭证信息', code=200,
dependencies=[ msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务',
Depends(RequestPermission('sys:plugin:install')), ),
DependsRBAC, )
],
)
async def install_git_plugin(repo_url: Annotated[str, Query(description='插件 git 仓库地址')]) -> ResponseModel:
await plugin_service.install_git(repo_url=repo_url)
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
@router.delete( @router.delete(
'/uninstall', '/{plugin}',
summary='卸载插件', summary='卸载插件',
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录', description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
dependencies=[ dependencies=[
@@ -65,33 +59,28 @@ async def install_git_plugin(repo_url: Annotated[str, Query(description='插件
DependsRBAC, DependsRBAC,
], ],
) )
async def uninstall_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel: async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin) await plugin_service.uninstall(plugin=plugin)
return response_base.success(res=CustomResponseCode.PLUGIN_UNINSTALL_SUCCESS) return response_base.success(
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务'),
)
@router.post( @router.put(
'/status', '/{plugin}/status',
summary='更新插件状态', summary='更新插件状态',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:plugin:status')), Depends(RequestPermission('sys:plugin:edit')),
DependsRBAC, DependsRBAC,
], ],
) )
async def update_plugin_status(plugin: Annotated[str, Query(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( @router.get('/{plugin}', summary='下载插件', dependencies=[DependsJwtAuth])
'/zip/{plugin}', async def download_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> StreamingResponse:
summary='打包插件',
dependencies=[
Depends(RequestPermission('sys:plugin:zip')),
DependsRBAC,
],
)
async def build_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(
bio, bio,
+38 -38
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3 from typing import Annotated
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.menu import GetMenuTree
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
DeleteRoleParam,
GetRoleDetail, GetRoleDetail,
GetRoleWithRelationDetail, GetRoleWithRelationDetail,
UpdateRoleMenuParam, UpdateRoleMenuParam,
@@ -13,49 +13,44 @@ from backend.app.admin.schema.role import (
UpdateRoleScopeParam, UpdateRoleScopeParam,
) )
from backend.app.admin.service.role_service import role_service from backend.app.admin.service.role_service import role_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.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 RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有角色', dependencies=[DependsJwtAuth])
async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]: async def get_all_roles(db: CurrentSession) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_all() data = await role_service.get_all(db=db)
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{pk}/all', summary='获取用户所有角色', dependencies=[DependsJwtAuth]) @router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
async def get_user_all_roles( async def get_role_menu_tree(
pk: Annotated[int, Path(description='用户 ID')], db: CurrentSession,
) -> ResponseSchemaModel[list[GetRoleDetail]]:
data = await role_service.get_users(pk=pk)
return response_base.success(data=data)
@router.get('/{pk}/menus', summary='获取角色所有菜单', dependencies=[DependsJwtAuth])
async def get_role_all_menus(
pk: Annotated[int, Path(description='角色 ID')], pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any] | None]]: ) -> ResponseSchemaModel[list[GetMenuTree] | None]:
menu = await role_service.get_menu_tree(pk=pk) menu = await role_service.get_menu_tree(db=db, pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth]) @router.get('/{pk}/scopes', summary='获取角色所有数据范围', dependencies=[DependsJwtAuth])
async def get_role_all_scopes(pk: Annotated[int, Path(description='角色 ID')]) -> ResponseSchemaModel[list[int]]: async def get_role_scopes(
rule = await role_service.get_scopes(pk=pk) db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
) -> ResponseSchemaModel[list[int]]:
rule = await role_service.get_scopes(db=db, pk=pk)
return response_base.success(data=rule) return response_base.success(data=rule)
@router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取角色详情', dependencies=[DependsJwtAuth])
async def get_role( async def get_role(
pk: Annotated[int, Path(description='角色 ID')], db: CurrentSession, pk: Annotated[int, Path(description='角色 ID')]
) -> ResponseSchemaModel[GetRoleWithRelationDetail]: ) -> ResponseSchemaModel[GetRoleWithRelationDetail]:
data = await role_service.get(pk=pk) data = await role_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -67,13 +62,12 @@ async def get_role(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_roles( async def get_roles_paginated(
db: CurrentSession, db: CurrentSession,
name: Annotated[str | None, Query(description='角色名称')] = None, name: Annotated[str | None, Query(description='角色名称')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[PageData[GetRoleDetail]]: ) -> ResponseSchemaModel[PageData[GetRoleDetail]]:
role_select = await role_service.get_select(name=name, status=status) page_data = await role_service.get_list(db=db, name=name, status=status)
page_data = await paging_data(db, role_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -85,8 +79,8 @@ async def get_pagination_roles(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_role(obj: CreateRoleParam) -> ResponseModel: async def create_role(db: CurrentSessionTransaction, obj: CreateRoleParam) -> ResponseModel:
await role_service.create(obj=obj) await role_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -98,15 +92,17 @@ async def create_role(obj: CreateRoleParam) -> ResponseModel:
DependsRBAC, DependsRBAC,
], ],
) )
async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam) -> ResponseModel: async def update_role(
count = await role_service.update(pk=pk, obj=obj) db: CurrentSessionTransaction, pk: Annotated[int, Path(description='角色 ID')], obj: UpdateRoleParam
) -> ResponseModel:
count = await role_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put( @router.put(
'/{pk}/menu', '/{pk}/menus',
summary='更新角色菜单', summary='更新角色菜单',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:menu:edit')), Depends(RequestPermission('sys:role:menu:edit')),
@@ -114,16 +110,18 @@ async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: Up
], ],
) )
async def update_role_menus( async def update_role_menus(
pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='角色 ID')],
menu_ids: UpdateRoleMenuParam,
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids) count = await role_service.update_role_menu(db=db, pk=pk, menu_ids=menu_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put( @router.put(
'/{pk}/scope', '/{pk}/scopes',
summary='更新角色数据范围', summary='更新角色数据范围',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:role:scope:edit')), Depends(RequestPermission('sys:role:scope:edit')),
@@ -131,9 +129,11 @@ async def update_role_menus(
], ],
) )
async def update_role_scopes( async def update_role_scopes(
pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='角色 ID')],
scope_ids: UpdateRoleScopeParam,
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids) count = await role_service.update_role_scope(db=db, pk=pk, scope_ids=scope_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@@ -147,8 +147,8 @@ async def update_role_scopes(
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_role(pk: Annotated[list[int], Query(description='角色 ID 列表')]) -> ResponseModel: async def delete_roles(db: CurrentSessionTransaction, obj: DeleteRoleParam) -> ResponseModel:
count = await role_service.delete(pk=pk) count = await role_service.delete(db=db, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated
from fastapi import APIRouter, File, UploadFile
from backend.common.dataclasses import UploadUrl
from backend.common.enums import FileType
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth
from backend.utils.file_ops import file_verify, upload_file
router = APIRouter()
@router.post('/image', summary='上传图片', dependencies=[DependsJwtAuth])
async def upload_image(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
file_verify(file, FileType.image)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
@router.post('/video', summary='上传视频', dependencies=[DependsJwtAuth])
async def upload_video(file: Annotated[UploadFile, File()]) -> ResponseSchemaModel[UploadUrl]:
file_verify(file, FileType.video)
filename = await upload_file(file)
return response_base.success(data={'url': f'/static/upload/{filename}'})
+96 -60
View File
@@ -1,71 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Body, Depends, Path, Query, Request
from backend.app.admin.schema.role import GetRoleDetail
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
GetCurrentUserInfoWithRelationDetail, GetCurrentUserInfoWithRelationDetail,
GetUserInfoWithRelationDetail, GetUserInfoWithRelationDetail,
RegisterUserParam,
ResetPasswordParam, ResetPasswordParam,
UpdateUserParam, UpdateUserParam,
) )
from backend.app.admin.service.user_service import user_service from backend.app.admin.service.user_service import user_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.enums import UserPermissionType
from backend.common.pagination import DependsPagination, PageData
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth, DependsSuperUser
from backend.common.security.permission import RequestPermission from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.database.db import CurrentSession from backend.database.db import CurrentSession, CurrentSessionTransaction
router = APIRouter() router = APIRouter()
@router.post('/register', summary='注册用户')
async def register_user(obj: RegisterUserParam) -> ResponseModel:
await user_service.register(obj=obj)
return response_base.success()
@router.post('/add', summary='添加用户', dependencies=[DependsRBAC])
async def add_user(request: Request, obj: AddUserParam) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.add(request=request, obj=obj)
data = await user_service.get_userinfo(username=obj.username)
return response_base.success(data=data)
@router.post('/password/reset', summary='密码重置', dependencies=[DependsJwtAuth])
async def password_reset(request: Request, obj: ResetPasswordParam) -> ResponseModel:
count = await user_service.pwd_reset(request=request, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
@router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth]) @router.get('/me', summary='获取当前用户信息', dependencies=[DependsJwtAuth])
async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]: async def get_current_user(request: Request) -> ResponseSchemaModel[GetCurrentUserInfoWithRelationDetail]:
data = request.user.model_dump() data = request.user.model_dump()
return response_base.success(data=data) return response_base.success(data=data)
@router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取用户信息', dependencies=[DependsJwtAuth])
async def get_user( async def get_userinfo(
username: Annotated[str, Path(description='用户名')], db: CurrentSession,
pk: Annotated[int, Path(description='用户 ID')],
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]: ) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
data = await user_service.get_userinfo(username=username) data = await user_service.get_userinfo(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) @router.get('/{pk}/roles', summary='获取用户所有角色', dependencies=[DependsJwtAuth])
async def update_user( async def get_user_roles(
request: Request, username: Annotated[str, Path(description='用户')], obj: UpdateUserParam db: CurrentSession, pk: Annotated[int, Path(description='用户 ID')]
) -> ResponseModel: ) -> ResponseSchemaModel[list[GetRoleDetail]]:
count = await user_service.update(request=request, username=username, obj=obj) data = await user_service.get_roles(db=db, pk=pk)
if count > 0: return response_base.success(data=data)
return response_base.success()
return response_base.fail()
@router.get( @router.get(
@@ -76,61 +53,120 @@ async def update_user(
DependsPagination, DependsPagination,
], ],
) )
async def get_pagination_users( async def get_users_paginated(
db: CurrentSession, db: CurrentSession,
dept: Annotated[int | None, Query(description='部门 ID')] = None, dept: Annotated[int | None, Query(description='部门 ID')] = None,
username: Annotated[str | None, Query(description='用户名')] = None, username: Annotated[str | None, Query(description='用户名')] = None,
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[PageData[GetUserInfoWithRelationDetail]]: ) -> ResponseSchemaModel[PageData[GetUserInfoWithRelationDetail]]:
user_select = await user_service.get_select(dept=dept, username=username, phone=phone, status=status) page_data = await user_service.get_list(db=db, dept=dept, username=username, phone=phone, status=status)
page_data = await paging_data(db, user_select)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@router.put('/{pk}/super', summary='修改用户超级权限', dependencies=[DependsRBAC]) @router.post('', summary='创建用户', dependencies=[DependsSuperUser])
async def super_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def create_user(
count = await user_service.update_permission(request=request, pk=pk) db: CurrentSessionTransaction, obj: AddUserParam
) -> ResponseSchemaModel[GetUserInfoWithRelationDetail]:
await user_service.create(db=db, obj=obj)
data = await user_service.get_userinfo(db=db, username=obj.username)
return response_base.success(data=data)
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsSuperUser])
async def update_user(
db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='用户 ID')],
obj: UpdateUserParam,
) -> ResponseModel:
count = await user_service.update(db=db, pk=pk, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put('/{pk}/staff', summary='修改用户后台登录权限', dependencies=[DependsRBAC]) @router.put('/{pk}/permissions', summary='更新用户权限', dependencies=[DependsSuperUser])
async def staff_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def update_user_permission(
count = await user_service.update_staff(request=request, pk=pk) db: CurrentSessionTransaction,
request: Request,
pk: Annotated[int, Path(description='用户 ID')],
type: Annotated[UserPermissionType, Query(description='权限类型')],
) -> ResponseModel:
count = await user_service.update_permission(db=db, request=request, pk=pk, type=type)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put('/{pk}/status', summary='修改用户状态', dependencies=[DependsRBAC]) @router.put('/me/password', summary='更新当前用户密码', dependencies=[DependsJwtAuth])
async def status_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def update_user_password(
count = await user_service.update_status(request=request, pk=pk) db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
) -> ResponseModel:
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.put('/{pk}/multi', summary='修改用户多端登录状态', dependencies=[DependsRBAC]) @router.put('/{pk}/password', summary='重置用户密码', dependencies=[DependsSuperUser])
async def multi_set(request: Request, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel: async def reset_user_password(
count = await user_service.update_multi_login(request=request, pk=pk) db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='用户 ID')],
password: Annotated[str, Body(embed=True, description='新密码')],
) -> ResponseModel:
count = await user_service.reset_password(db=db, pk=pk, password=password)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth])
async def update_user_nickname(
db: CurrentSessionTransaction,
request: Request,
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
) -> ResponseModel:
count = await user_service.update_nickname(db=db, user_id=request.user.id, nickname=nickname)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth])
async def update_user_avatar(
db: CurrentSessionTransaction,
request: Request,
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
) -> ResponseModel:
count = await user_service.update_avatar(db=db, user_id=request.user.id, avatar=avatar)
if count > 0:
return response_base.success()
return response_base.fail()
@router.put('/me/email', summary='更新当前用户邮箱', dependencies=[DependsJwtAuth])
async def update_user_email(
db: CurrentSessionTransaction,
request: Request,
captcha: Annotated[str, Body(embed=True, description='邮箱验证码')],
email: Annotated[str, Body(embed=True, description='用户邮箱')],
) -> ResponseModel:
count = await user_service.update_email(db=db, user_id=request.user.id, captcha=captcha, email=email)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
@router.delete( @router.delete(
path='/{username}', path='/{pk}',
summary='用户注销', summary='删除用户',
description='用户注销 != 用户登出,注销之后用户将从数据库删除',
dependencies=[ dependencies=[
Depends(RequestPermission('sys:user:del')), Depends(RequestPermission('sys:user:del')),
DependsRBAC, DependsRBAC,
], ],
) )
async def delete_user(username: Annotated[str, Path(description='用户')]) -> ResponseModel: async def delete_user(db: CurrentSessionTransaction, pk: Annotated[int, Path(description='用户 ID')]) -> ResponseModel:
count = await user_service.delete(username=username) count = await user_service.delete(db=db, pk=pk)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
return response_base.fail() return response_base.fail()
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+11 -18
View File
@@ -1,10 +1,7 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, and_, desc, select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule from backend.app.admin.model import DataRule
@@ -24,23 +21,19 @@ class CRUDDataRule(CRUDPlus[DataRule]):
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_list(self, name: str | None) -> Select: async def get_select(self, name: str | None) -> Select:
""" """
获取规则列表 获取规则列表查询表达式
:param name: 规则名称 :param name: 规则名称
:return: :return:
""" """
stmt = select(self.model).options(noload(self.model.scope)).order_by(desc(self.model.created_time)) filters = {}
filters = []
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if filters: return await self.select_order('id', **filters)
stmt = stmt.where(and_(*filters))
return stmt
async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None: async def get_by_name(self, db: AsyncSession, name: str) -> DataRule | None:
""" """
@@ -82,15 +75,15 @@ class CRUDDataRule(CRUDPlus[DataRule]):
""" """
return await self.update_model(db, pk, obj) return await self.update_model(db, pk, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除规则 批量删除规则
:param db: 数据库会话 :param db: 数据库会话
:param pk: 规则 ID 列表 :param pks: 规则 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule) data_rule_dao: CRUDDataRule = CRUDDataRule(DataRule)
+56 -36
View File
@@ -1,12 +1,18 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from sqlalchemy import Select, and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload, selectinload
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import DataRule, DataScope from sqlalchemy import Select, delete, insert
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from backend.app.admin.model import DataRule, DataScope, data_scope_rule
from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
CreateDataScopeRuleParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.utils.serializers import select_join_serialize
class CRUDDataScope(CRUDPlus[DataScope]): class CRUDDataScope(CRUDPlus[DataScope]):
@@ -32,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:
""" """
获取数据范围关联数据 获取数据范围关联数据
@@ -40,34 +46,42 @@ class CRUDDataScope(CRUDPlus[DataScope]):
:param pk: 范围 ID :param pk: 范围 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.rules)).where(self.model.id == pk) result = await self.select_models(
data_scope = await db.execute(stmt) db,
return data_scope.scalars().first() 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),
],
)
async def get_list(self, name: str | None, status: int | None) -> Select: return select_join_serialize(result, relationships=['DataScope-m2m-DataRule:rules'])
async def get_all(self, db: AsyncSession) -> Sequence[DataScope]:
""" """
获取数据范围列表 获取所有数据范围
:param db: 数据库会话
:return:
"""
return await self.select_models(db)
async def get_select(self, name: str | None, status: int | None) -> Select:
"""
获取数据范围列表查询表达式
:param name: 范围名称 :param name: 范围名称
:param status: 范围状态 :param status: 范围状态
:return: :return:
""" """
stmt = ( filters = {}
select(self.model)
.options(noload(self.model.rules), noload(self.model.roles))
.order_by(desc(self.model.created_time))
)
filters = []
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if status is not None: if status is not None:
filters.append(self.model.status == status) filters['status'] = status
if filters: return await self.select_order('id', **filters)
stmt = stmt.where(and_(*filters))
return stmt
async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None: async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None:
""" """
@@ -90,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:
""" """
更新数据范围规则 更新数据范围规则
@@ -99,21 +114,26 @@ 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()
return len(current_data_scope.rules)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: data_scope_rule_data = [
CreateDataScopeRuleParam(data_scope_id=pk, data_rule_id=rule_id).model_dump() for rule_id in rule_ids.rules
]
data_scope_rule_stmt = insert(data_scope_rule)
await db.execute(data_scope_rule_stmt, data_scope_rule_data)
return len(rule_ids.rules)
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除数据范围 批量删除数据范围
:param db: 数据库会话 :param db: 数据库会话
:param pk: 范围 ID 列表 :param pks: 范围 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope) data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope)
+26 -26
View File
@@ -1,16 +1,13 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from typing import Sequence
from fastapi import Request from sqlalchemy import ColumnElement
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy_crud_plus import CRUDPlus, JoinConfig
from sqlalchemy_crud_plus import CRUDPlus
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]):
@@ -24,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:
""" """
@@ -34,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,
@@ -48,24 +45,26 @@ 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__eq': 0} filters = {'del_flag': False}
if name is not None: if name is not None:
filters.update(name__like=f'%{name}%') filters['name__like'] = f'%{name}%'
if leader is not None: if leader is not None:
filters.update(leader__like=f'%{leader}%') filters['leader__like'] = f'%{leader}%'
if phone is not None: if phone is not None:
filters.update(phone__startswith=phone) filters['phone__startswith'] = phone
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
return await self.select_models_order(db, 'sort', None, await filter_data_permission(db, request), **filters)
return await self.select_models_order(db, 'sort', 'desc', data_filter, **filters)
async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None: async def create(self, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
@@ -98,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:
""" """
获取部门及关联数据 获取部门及关联数据
@@ -106,9 +105,12 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.users)).where(self.model.id == dept_id) result = await self.select_model(
result = await db.execute(stmt) db,
return result.scalars().first() 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]:
""" """
@@ -118,9 +120,7 @@ class CRUDDept(CRUDPlus[Dept]):
:param dept_id: 部门 ID :param dept_id: 部门 ID
:return: :return:
""" """
stmt = select(self.model).where(self.model.parent_id == dept_id, self.model.del_flag == 0) return await self.select_models(db, parent_id=dept_id, del_flag=False)
result = await db.execute(stmt)
return result.scalars().all()
dept_dao: CRUDDept = CRUDDept(Dept) dept_dao: CRUDDept = CRUDDept(Dept)
+15 -13
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy import delete as sa_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
@@ -11,9 +10,9 @@ from backend.app.admin.schema.login_log import CreateLoginLogParam
class CRUDLoginLog(CRUDPlus[LoginLog]): class CRUDLoginLog(CRUDPlus[LoginLog]):
"""登录日志数据库操作类""" """登录日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select: async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取登录日志列表 获取登录日志列表查询表达式
:param username: 用户名 :param username: 用户名
:param status: 登录状态 :param status: 登录状态
@@ -21,12 +20,14 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username__like=f'%{username}%') filters['username__like'] = f'%{username}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
if ip is not None: if ip is not None:
filters.update(ip__like=f'%{ip}%') filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateLoginLogParam) -> None:
@@ -39,24 +40,25 @@ class CRUDLoginLog(CRUDPlus[LoginLog]):
""" """
await self.create_model(db, obj, commit=True) await self.create_model(db, obj, commit=True)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
""" """
删除登录日志 批量删除登录日志
:param db: 数据库会话 :param db: 数据库会话
:param pk: 登录日志 ID 列表 :param pks: 登录日志 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
async def delete_all(self, db: AsyncSession) -> int: @staticmethod
async def delete_all(db: AsyncSession) -> None:
""" """
删除所有日志 删除所有日志
:param db: 数据库会话 :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) await db.execute(sa_delete(LoginLog))
login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog) login_log_dao: CRUDLoginLog = CRUDLoginLog(LoginLog)
+20 -23
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import and_, asc, select from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import Menu from backend.app.admin.model import Menu, role_menu
from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, UpdateMenuParam
@@ -44,28 +41,28 @@ class CRUDMenu(CRUDPlus[Menu]):
:return: :return:
""" """
filters = {} filters = {}
if title is not None: if title is not None:
filters.update(title_like=f'%{title}%') filters['title__like'] = f'%{title}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status'] = status
return await self.select_models_order(db, 'sort', **filters) return await self.select_models_order(db, 'sort', **filters)
async def get_sidebar(self, db: AsyncSession, superuser: bool, menu_ids: list[int | None]) -> Sequence[Menu]: async def get_sidebar(self, db: AsyncSession, menu_ids: list[int] | None) -> Sequence[Menu]:
""" """
获取角色菜单列表 获取用户的菜单侧边栏
:param db: 数据库会话 :param db: 数据库会话
:param superuser: 是否超级管理员
:param menu_ids: 菜单 ID 列表 :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
stmt = select(self.model).order_by(asc(self.model.sort)) filters = {'type__in': [0, 1, 3, 4]}
filters = [self.model.type.in_([0, 1])]
if not superuser: if menu_ids:
filters.append(self.model.id.in_(menu_ids)) filters['id__in'] = menu_ids
stmt = stmt.where(and_(*filters))
menu = await db.execute(stmt) return await self.select_models_order(db, 'sort', 'asc', **filters)
return menu.scalars().all()
async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None: async def create(self, db: AsyncSession, obj: CreateMenuParam) -> None:
""" """
@@ -96,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]:
""" """
获取子菜单列表 获取子菜单列表
@@ -106,10 +106,7 @@ class CRUDMenu(CRUDPlus[Menu]):
:param menu_id: 菜单 ID :param menu_id: 菜单 ID
:return: :return:
""" """
stmt = select(self.model).options(selectinload(self.model.children)).where(self.model.id == menu_id) return await self.select_models(db, parent_id=menu_id)
result = await db.execute(stmt)
menu = result.scalars().first()
return menu.children
menu_dao: CRUDMenu = CRUDMenu(Menu) menu_dao: CRUDMenu = CRUDMenu(Menu)
+26 -14
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy import delete as sa_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
@@ -11,9 +10,9 @@ from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]): class CRUDOperaLogDao(CRUDPlus[OperaLog]):
"""操作日志数据库操作类""" """操作日志数据库操作类"""
async def get_list(self, username: str | None, status: int | None, ip: str | None) -> Select: async def get_select(self, username: str | None, status: int | None, ip: str | None) -> Select:
""" """
获取操作日志列表 获取操作日志列表查询表达式
:param username: 用户名 :param username: 用户名
:param status: 操作状态 :param status: 操作状态
@@ -21,12 +20,14 @@ class CRUDOperaLogDao(CRUDPlus[OperaLog]):
:return: :return:
""" """
filters = {} filters = {}
if username is not None: if username is not None:
filters.update(username__like=f'%{username}%') filters['username__like'] = f'%{username}%'
if status is not None: if status is not None:
filters.update(status=status) filters['status__eq'] = status
if ip is not None: if ip is not None:
filters.update(ip__like=f'%{ip}%') filters['ip__like'] = f'%{ip}%'
return await self.select_order('created_time', 'desc', **filters) return await self.select_order('created_time', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None: async def create(self, db: AsyncSession, obj: CreateOperaLogParam) -> None:
@@ -34,29 +35,40 @@ class CRUDOperaLogDao(CRUDPlus[OperaLog]):
创建操作日志 创建操作日志
:param db: 数据库会话 :param db: 数据库会话
:param obj: 创建操作日志参数 :param obj: 操作日志创建参数
:return: :return:
""" """
await self.create_model(db, obj) await self.create_model(db, obj)
async def delete(self, db: AsyncSession, pk: list[int]) -> int: async def bulk_create(self, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
""" """
删除操作日志 批量创建操作日志
:param db: 数据库会话 :param db: 数据库会话
:param pk: 操作日志 ID 列表 :param objs: 操作日志创建参数列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pk) await self.create_models(db, objs)
async def delete_all(self, db: AsyncSession) -> int: async def delete(self, db: AsyncSession, pks: list[int]) -> int:
"""
批量删除操作日志
:param db: 数据库会话
:param pks: 操作日志 ID 列表
:return:
"""
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
@staticmethod
async def delete_all(db: AsyncSession) -> None:
""" """
删除所有日志 删除所有日志
:param db: 数据库会话 :param db: 数据库会话
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True) await db.execute(sa_delete(OperaLog))
opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog) opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog)
+68 -56
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from typing import Sequence
from sqlalchemy import Select, and_, desc, 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_crud_plus import CRUDPlus
from backend.app.admin.model import DataScope, Menu, Role, User 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]):
@@ -29,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:
""" """
获取角色及关联数据 获取角色及关联数据
@@ -37,13 +51,18 @@ class CRUDRole(CRUDPlus[Role]):
:param role_id: 角色 ID :param role_id: 角色 ID
:return: :return:
""" """
stmt = ( result = await self.select_models(
select(self.model) db,
.options(selectinload(self.model.menus), selectinload(self.model.scopes)) id=role_id,
.where(self.model.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),
],
) )
role = await db.execute(stmt)
return role.scalars().first() 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]:
""" """
@@ -54,42 +73,23 @@ class CRUDRole(CRUDPlus[Role]):
""" """
return await self.select_models(db) return await self.select_models(db)
async def get_users(self, db: AsyncSession, user_id: int) -> Sequence[Role]: async def get_select(self, name: str | None, status: int | None) -> Select:
""" """
获取用户角色列表 获取角色列表查询表达式
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
stmt = select(self.model).join(self.model.users).where(User.id == user_id)
roles = await db.execute(stmt)
return roles.scalars().all()
async def get_list(self, name: str | None, status: int | None) -> Select:
"""
获取角色列表
:param name: 角色名称 :param name: 角色名称
:param status: 角色状态 :param status: 角色状态
:return: :return:
""" """
stmt = (
select(self.model)
.options(noload(self.model.users), noload(self.model.menus), noload(self.model.scopes))
.order_by(desc(self.model.created_time))
)
filters = [] filters = {}
if name is not None: if name is not None:
filters.append(self.model.name.like(f'%{name}%')) filters['name__like'] = f'%{name}%'
if status is not None: if status is not None:
filters.append(self.model.status == status) filters['status'] = status
if filters: return await self.select_order('id', **filters)
stmt = stmt.where(and_(*filters))
return stmt
async def get_by_name(self, db: AsyncSession, name: str) -> Role | None: async def get_by_name(self, db: AsyncSession, name: str) -> Role | None:
""" """
@@ -122,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:
""" """
更新角色菜单 更新角色菜单
@@ -131,13 +132,19 @@ 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: 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:
""" """
更新角色数据范围 更新角色数据范围
@@ -146,21 +153,26 @@ 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()
return len(current_role.scopes)
async def delete(self, db: AsyncSession, role_id: list[int]) -> int: role_scope_data = [
CreateRoleScopeParam(role_id=role_id, data_scope_id=scope_id).model_dump() for scope_id in scope_ids.scopes
]
role_scope_stmt = insert(role_data_scope)
await db.execute(role_scope_stmt, role_scope_data)
return len(scope_ids.scopes)
async def delete(self, db: AsyncSession, role_ids: list[int]) -> int:
""" """
删除角色 批量删除角色
:param db: 数据库会话 :param db: 数据库会话
:param role_id: 角色 ID 列表 :param role_ids: 角色 ID 列表
:return: :return:
""" """
return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_id) return await self.delete_model_by_column(db, allow_multiple=True, id__in=role_ids)
role_dao: CRUDRole = CRUDRole(Role) role_dao: CRUDRole = CRUDRole(Role)
+207 -149
View File
@@ -1,20 +1,32 @@
#!/usr/bin/env python3 from typing import Any
# -*- coding: utf-8 -*-
import bcrypt import bcrypt
from sqlalchemy import and_, desc, 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,
AddUserParam, AddUserParam,
RegisterUserParam, 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.utils.import_parse import import_module_cached
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -51,35 +63,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 email: 电子邮箱
:return:
"""
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 username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return: :return:
""" """
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username) filters = {}
async def create(self, db: AsyncSession, obj: RegisterUserParam, *, social: bool = False) -> None: 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
:param db: 数据库会话 return await self.select_order(
:param obj: 注册用户参数 'id',
:param social: 是否社交用户 'desc',
:return: join_conditions=[
""" JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
if not social: JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
salt = bcrypt.gensalt() JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
obj.password = get_hash_password(obj.password, salt) ],
dict_obj = obj.model_dump() **filters,
dict_obj.update({'is_staff': True, 'salt': salt}) )
else:
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
db.add(new_user)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None: async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
""" """
@@ -91,144 +115,136 @@ 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()
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int: 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:
"""
通过 OAuth2 添加用户
:param db: 数据库会话
:param obj: 注册用户参数
:return:
"""
dict_obj = obj.model_dump()
dict_obj.update({'is_staff': True, 'salt': None})
new_user = self.model(**dict_obj)
db.add(new_user)
await db.flush()
role_stmt = select(Role)
result = await db.execute(role_stmt)
role = result.scalars().first() # 默认绑定第一个角色
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() role_stmt = select(Role).where(Role.id.in_(role_ids))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
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 delete(self, db: AsyncSession, user_id: int) -> int: 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 db: 数据库会话
:param user_id: 用户 ID :param user_id: 用户 ID
:return: :return:
""" """
return await self.delete_model(db, user_id) return await self.update_model(db, user_id, {'last_password_changed_time': timezone.now()})
async def check_email(self, db: AsyncSession, email: str) -> User | None: async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
""" """
检查邮箱是否已被注册 更新用户昵称
:param db: 数据库会话 :param db: 数据库会话
:param email: 电子邮箱 :param user_id: 用户 ID
:param nickname: 用户昵称
:return: :return:
""" """
return await self.select_model_by_column(db, email=email) return await self.update_model(db, user_id, {'nickname': nickname})
async def reset_password(self, db: AsyncSession, pk: int, new_pwd: str) -> int: async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
"""
更新用户头像
:param db: 数据库会话
:param user_id: 用户 ID
:param avatar: 头像地址
:return:
"""
return await self.update_model(db, user_id, {'avatar': avatar})
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
"""
更新用户邮箱
:param db: 数据库会话
:param user_id: 用户 ID
:param email: 邮箱
:return:
"""
return await self.update_model(db, user_id, {'email': email})
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
""" """
重置用户密码 重置用户密码
:param db: 数据库会话 :param db: 数据库会话
:param pk: 用户 ID :param pk: 用户 ID
:param new_pwd: 新密码已加密 :param password: 新密码
:return: :return:
""" """
return await self.update_model(db, pk, {'password': new_pwd}) salt = bcrypt.gensalt()
new_pwd = get_hash_password(password, salt)
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
async def get_list(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select: async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
"""
获取用户列表
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
stmt = (
select(self.model)
.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)),
)
.order_by(desc(self.model.join_time))
)
filters = []
if dept:
filters.append(self.model.dept_id == dept)
if username:
filters.append(self.model.username.like(f'%{username}%'))
if phone:
filters.append(self.model.phone.like(f'%{phone}%'))
if status is not None:
filters.append(self.model.status == status)
if filters:
stmt = stmt.where(and_(*filters))
return stmt
async def get_super(self, db: AsyncSession, user_id: int) -> bool:
"""
获取用户是否为超级管理员
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user = await self.get(db, user_id)
return user.is_superuser
async def get_staff(self, db: AsyncSession, user_id: int) -> bool:
"""
获取用户是否可以登录后台
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user = await self.get(db, user_id)
return user.is_staff
async def get_status(self, db: AsyncSession, user_id: int) -> int:
"""
获取用户状态
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user = await self.get(db, user_id)
return user.status
async def get_multi_login(self, db: AsyncSession, user_id: int) -> bool:
"""
获取用户是否允许多端登录
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user = await self.get(db, user_id)
return user.is_multi_login
async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int:
""" """
设置用户超级管理员状态 设置用户超级管理员状态
@@ -239,7 +255,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'is_superuser': is_super}) return await self.update_model(db, user_id, {'is_superuser': is_super})
async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int: async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
""" """
设置用户后台登录状态 设置用户后台登录状态
@@ -261,7 +277,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'status': status}) return await self.update_model(db, user_id, {'status': status})
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int: async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
""" """
设置用户多端登录状态 设置用户多端登录状态
@@ -272,9 +288,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:
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None """
) -> User | None: 删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
try:
user_social = import_module_cached('backend.plugin.oauth2.crud.crud_user_social')
user_social_dao = user_social.user_social_dao
except (ImportError, AttributeError):
pass
else:
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
async def get_join(
self,
db: AsyncSession,
*,
user_id: int | None = None,
username: str | None = None,
) -> Any | None:
""" """
获取用户关联信息 获取用户关联信息
@@ -283,22 +324,39 @@ class CRUDUser(CRUDPlus[User]):
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
stmt = select(self.model).options( filters = {}
selectinload(self.model.dept),
selectinload(self.model.roles).options(selectinload(Role.menus), selectinload(Role.scopes)), if user_id:
filters['id'] = user_id
if username:
filters['username'] = username
result = await self.select_models(
db,
join_conditions=[
JoinConfig(model=Dept, join_on=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(
if user_id: result,
filters.append(self.model.id == user_id) relationships=[
if username: 'User-m2o-Dept',
filters.append(self.model.username == username) 'User-m2m-Role',
'Role-m2m-Menu',
if filters: 'Role-m2m-DataScope:scopes',
stmt = stmt.where(and_(*filters)) 'DataScope-m2m-DataRule:rules',
],
user = await db.execute(stmt) )
return user.scalars().first()
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)
+13 -10
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3 from backend.app.admin.model.data_rule import DataRule as DataRule
# -*- coding: utf-8 -*- from backend.app.admin.model.data_scope import DataScope as DataScope
from backend.app.admin.model.data_rule import DataRule from backend.app.admin.model.dept import Dept as Dept
from backend.app.admin.model.data_scope import DataScope from backend.app.admin.model.login_log import LoginLog as LoginLog
from backend.app.admin.model.dept import Dept from backend.app.admin.model.m2m import data_scope_rule as data_scope_rule
from backend.app.admin.model.login_log import LoginLog from backend.app.admin.model.m2m import role_data_scope as role_data_scope
from backend.app.admin.model.menu import Menu from backend.app.admin.model.m2m import role_menu as role_menu
from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.m2m import user_role as user_role
from backend.app.admin.model.role import Role from backend.app.admin.model.menu import Menu as Menu
from backend.app.admin.model.user import User from backend.app.admin.model.opera_log import OperaLog as OperaLog
from backend.app.admin.model.role import Role as Role
from backend.app.admin.model.user import User as User
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
+7 -21
View File
@@ -1,17 +1,9 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ForeignKey, String
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 DataScope
class DataRule(Base): class DataRule(Base):
"""数据规则表""" """数据规则表"""
@@ -19,17 +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='规则值')
# 数据范围规则一对多
scope_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_data_scope.id', ondelete='SET NULL'), default=None, comment='数据范围关联 ID'
)
scope: Mapped[DataScope] = relationship(init=False, back_populates='rules')
+3 -18
View File
@@ -1,18 +1,9 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from __future__ import annotations
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_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):
"""数据范围表""" """数据范围表"""
@@ -20,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, back_populates='scope')
# 角色数据范围多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='scopes')
+9 -27
View File
@@ -1,18 +1,9 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING, Optional from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import 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):
"""部门表""" """部门表"""
@@ -20,22 +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')
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID'
)
parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent')
# 部门用户一对多
users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
+19 -19
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, 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, id_key from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -17,19 +14,22 @@ 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(256), 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(DateTime(timezone=True), comment='登录时间') login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='创建时间' TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
) )
+25 -21
View File
@@ -1,35 +1,39 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from sqlalchemy import INT, Column, ForeignKey, Integer, Table
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', INT, 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', Integer, 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', Integer, 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', INT, 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', Integer, 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', Integer, 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', INT, 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', Integer, 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', )
Integer,
ForeignKey('sys_data_scope.id', ondelete='CASCADE'), # 数据范围规则表
primary_key=True, data_scope_rule = sa.Table(
comment='数据范围 ID', 'sys_data_scope_rule',
), MappedBase.metadata,
sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键ID'),
sa.Column('data_scope_id', sa.BigInteger, primary_key=True, comment='数据范围 ID'),
sa.Column('data_rule_id', sa.BigInteger, primary_key=True, comment='数据规则 ID'),
) )
+13 -35
View File
@@ -1,19 +1,8 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING, Optional from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import 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):
@@ -22,30 +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'), default=None, comment='外链地址' remark: Mapped[str | None] = mapped_column(UniversalText, 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')
ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID'
)
parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent')
# 菜单角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
+21 -24
View File
@@ -1,13 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, 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, id_key from backend.common.model import DataClassBase, TimeZone, UniversalText, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -17,25 +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(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='设备')
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(DateTime(timezone=True), comment='操作时间') opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='创建时间' TimeZone, init=False, default_factory=timezone.now, comment='创建时间'
) )
+6 -27
View File
@@ -1,19 +1,8 @@
#!/usr/bin/env python3 import sqlalchemy as sa
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import 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_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):
@@ -22,17 +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正常)')
remark: Mapped[str | None] = mapped_column( is_filter_scopes: Mapped[bool] = mapped_column(default=True, comment='过滤数据权限(0否 1是)')
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
)
# 角色用户多对多
users: Mapped[list[User]] = relationship(init=False, secondary=sys_user_role, back_populates='roles')
# 角色菜单多对多
menus: Mapped[list[Menu]] = relationship(init=False, secondary=sys_role_menu, back_populates='roles')
# 角色数据范围多对多
scopes: Mapped[list[DataScope]] = relationship(init=False, secondary=sys_role_data_scope, back_populates='roles')
+22 -42
View File
@@ -1,22 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_user_role from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, 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):
"""用户表""" """用户表"""
@@ -24,36 +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), unique=True, 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] = mapped_column(String(50), unique=True, index=True, comment='邮箱') email: Mapped[str | None] = mapped_column(sa.String(256), default=None, unique=True, index=True, comment='邮箱')
is_superuser: Mapped[bool] = mapped_column( phone: Mapped[str | None] = mapped_column(sa.String(11), default=None, comment='手机号')
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)' avatar: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='头像')
)
is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)'
)
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_multi_login: Mapped[bool] = mapped_column( is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)' is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
) is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像') join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment='手机号')
join_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='注册时间'
)
last_login_time: Mapped[datetime | None] = mapped_column( last_login_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), init=False, onupdate=timezone.now, comment='上次登录' TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
)
last_password_changed_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
) )
# 部门用户一对多 # 逻辑外键
dept_id: Mapped[int | None] = mapped_column( 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
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+3 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import Field from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
@@ -8,5 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase): class GetCaptchaDetail(SchemaBase):
"""验证码详情""" """验证码详情"""
image_type: str = Field(description='图片类型') is_enabled: bool = Field(description='是否启用')
expire_seconds: int = Field(description='过期秒数')
uuid: str = Field(description='图片唯一标识')
image: str = Field(description='图片内容') image: str = Field(description='图片内容')
+10 -6
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -14,8 +12,8 @@ class DataRuleSchemaBase(SchemaBase):
name: str = Field(description='规则名称') name: str = Field(description='规则名称')
model: str = Field(description='模型名称') model: str = Field(description='模型名称')
column: str = Field(description='字段名称') column: str = Field(description='字段名称')
operator: RoleDataRuleOperatorType = Field(RoleDataRuleOperatorType.AND, description='操作符(AND/OR') operator: RoleDataRuleOperatorType = Field(description='操作符(AND/OR')
expression: RoleDataRuleExpressionType = Field(RoleDataRuleExpressionType.eq, description='表达式类型') expression: RoleDataRuleExpressionType = Field(description='表达式类型')
value: str = Field(description='规则值') value: str = Field(description='规则值')
@@ -27,10 +25,16 @@ class UpdateDataRuleParam(DataRuleSchemaBase):
"""更新数据规则参数""" """更新数据规则参数"""
class DeleteDataRuleParam(SchemaBase):
"""删除数据规则参数"""
pks: list[int] = Field(description='规则 ID 列表')
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='创建时间')
@@ -41,4 +45,4 @@ class GetDataRuleColumnDetail(SchemaBase):
"""数据规则可用模型字段详情""" """数据规则可用模型字段详情"""
key: str = Field(description='字段名') key: str = Field(description='字段名')
comment: str = Field(description='字段评论') comment: str | None = Field(description='字段评论')
+15 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -13,7 +11,7 @@ class DataScopeBase(SchemaBase):
"""数据范围基础模型""" """数据范围基础模型"""
name: str = Field(description='名称') name: str = Field(description='名称')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(description='状态')
class CreateDataScopeParam(DataScopeBase): class CreateDataScopeParam(DataScopeBase):
@@ -24,12 +22,25 @@ 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):
"""更新数据范围规则参数""" """更新数据范围规则参数"""
rules: list[int] = Field(description='数据规则 ID 列表') rules: list[int] = Field(description='数据规则 ID 列表')
class DeleteDataScopeParam(SchemaBase):
"""删除数据范围参数"""
pks: list[int] = Field(description='数据范围 ID 列表')
class GetDataScopeDetail(DataScopeBase): class GetDataScopeDetail(DataScopeBase):
"""数据范围详情""" """数据范围详情"""
@@ -43,4 +54,4 @@ class GetDataScopeDetail(DataScopeBase):
class GetDataScopeWithRelationDetail(GetDataScopeDetail): class GetDataScopeWithRelationDetail(GetDataScopeDetail):
"""数据范围关联详情""" """数据范围关联详情"""
rules: list[GetDataRuleDetail] = Field([], description='数据规则列表') rules: list[GetDataRuleDetail | None] = Field([], description='数据规则列表')
+7 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -17,7 +15,7 @@ class DeptSchemaBase(SchemaBase):
leader: str | None = Field(None, description='负责人') leader: str | None = Field(None, description='负责人')
phone: CustomPhoneNumber | None = Field(None, description='联系电话') phone: CustomPhoneNumber | None = Field(None, description='联系电话')
email: CustomEmailStr | None = Field(None, description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(description='状态')
class CreateDeptParam(DeptSchemaBase): class CreateDeptParam(DeptSchemaBase):
@@ -37,3 +35,9 @@ class GetDeptDetail(DeptSchemaBase):
del_flag: bool = Field(description='是否删除') del_flag: bool = Field(description='是否删除')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
class GetDeptTree(GetDeptDetail):
"""获取部门树"""
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
+7 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -17,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='设备')
@@ -33,6 +31,12 @@ class UpdateLoginLogParam(LoginLogSchemaBase):
"""更新登录日志参数""" """更新登录日志参数"""
class DeleteLoginLogParam(SchemaBase):
"""删除登录日志参数"""
pks: list[int] = Field(description='登录日志 ID 列表')
class GetLoginLogDetail(LoginLogSchemaBase): class GetLoginLogDetail(LoginLogSchemaBase):
"""登录日志详情""" """登录日志详情"""
+10 -6
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -17,12 +15,12 @@ class MenuSchemaBase(SchemaBase):
parent_id: int | None = Field(None, description='菜单父级 ID') parent_id: int | None = Field(None, description='菜单父级 ID')
sort: int = Field(0, ge=0, description='排序') sort: int = Field(0, ge=0, description='排序')
icon: str | None = Field(None, description='图标') icon: str | None = Field(None, description='图标')
type: MenuType = Field(MenuType.directory, description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)') type: MenuType = Field(description='菜单类型(0目录 1菜单 2按钮 3内嵌 4外链)')
component: str | None = Field(None, description='组件路径') component: str | None = Field(None, description='组件路径')
perms: str | None = Field(None, description='权限标识') perms: str | None = Field(None, description='权限标识')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(description='状态')
display: StatusType = Field(StatusType.enable, description='是否显示') display: StatusType = Field(description='是否显示')
cache: StatusType = Field(StatusType.enable, description='是否缓存') cache: StatusType = Field(description='是否缓存')
link: str | None = Field(None, description='外链地址') link: str | None = Field(None, description='外链地址')
remark: str | None = Field(None, description='备注') remark: str | None = Field(None, description='备注')
@@ -43,3 +41,9 @@ class GetMenuDetail(MenuSchemaBase):
id: int = Field(description='菜单 ID') id: int = Field(description='菜单 ID')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
class GetMenuTree(GetMenuDetail):
"""获取菜单树"""
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
+8 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -21,12 +19,12 @@ 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='设备')
args: dict[str, Any] | None = Field(None, description='请求参数') args: dict[str, Any] | None = Field(None, description='请求参数')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(description='状态')
code: str = Field(description='状态码') code: str = Field(description='状态码')
msg: str | None = Field(None, description='消息') msg: str | None = Field(None, description='消息')
cost_time: float = Field(description='耗时') cost_time: float = Field(description='耗时')
@@ -41,6 +39,12 @@ class UpdateOperaLogParam(OperaLogSchemaBase):
"""更新操作日志参数""" """更新操作日志参数"""
class DeleteOperaLogParam(SchemaBase):
"""删除操作日志参数"""
pks: list[int] = Field(description='操作日志 ID 列表')
class GetOperaLogDetail(OperaLogSchemaBase): class GetOperaLogDetail(OperaLogSchemaBase):
"""操作日志详情""" """操作日志详情"""
+24 -5
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime 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
@@ -14,7 +12,8 @@ class RoleSchemaBase(SchemaBase):
"""角色基础模型""" """角色基础模型"""
name: str = Field(description='角色名称') name: str = Field(description='角色名称')
status: StatusType = Field(StatusType.enable, description='状态') status: StatusType = Field(description='状态')
is_filter_scopes: bool = Field(True, description='过滤数据权限')
remark: str | None = Field(None, description='备注') remark: str | None = Field(None, description='备注')
@@ -26,12 +25,32 @@ class UpdateRoleParam(RoleSchemaBase):
"""更新角色参数""" """更新角色参数"""
class DeleteRoleParam(SchemaBase):
"""删除角色参数"""
pks: list[int] = Field(description='角色 ID 列表')
class CreateRoleMenuParam(SchemaBase):
"""创建角色菜单参数"""
role_id: int = Field(description='角色 ID')
menu_id: int = Field(description='菜单 ID')
class UpdateRoleMenuParam(SchemaBase): 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):
"""更新角色数据范围参数""" """更新角色数据范围参数"""
@@ -52,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 -2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import Field from pydantic import Field
@@ -32,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='用户信息')
+27 -19
View File
@@ -1,44 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any from typing import Annotated, Any
from pydantic import ConfigDict, EmailStr, 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 CustomPhoneNumber, SchemaBase from backend.common.schema import CustomEmailStr, CustomPhoneNumber, SchemaBase, ser_string
class AuthSchemaBase(SchemaBase): class AuthSchemaBase(SchemaBase):
"""用户认证基础模型""" """用户认证基础模型"""
username: str = Field(description='用户名') username: str = Field(description='用户名')
password: str | None = Field(description='密码') password: str = Field(description='密码')
class AuthLoginParam(AuthSchemaBase): class AuthLoginParam(AuthSchemaBase):
"""用户登录参数""" """用户登录参数"""
captcha: str = Field(description='验证码') uuid: str | None = Field(None, description='验证码 UUID')
captcha: str | None = Field(None, description='验证码')
class RegisterUserParam(AuthSchemaBase):
"""用户注册参数"""
nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱')
class AddUserParam(AuthSchemaBase): class AddUserParam(AuthSchemaBase):
"""添加用户参数""" """添加用户参数"""
nickname: str | None = Field(None, description='昵称')
email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号码')
dept_id: int = Field(description='部门 ID') dept_id: int = Field(description='部门 ID')
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):
"""添加 OAuth2 用户参数"""
password: str | None = Field(None, description='密码')
nickname: str | None = Field(None, description='昵称') nickname: str | None = Field(None, description='昵称')
email: EmailStr = Field(examples=['user@example.com'], description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
avatar: Annotated[HttpUrl, PlainSerializer(ser_string)] | None = Field(None, description='头像地址')
class ResetPasswordParam(SchemaBase): class ResetPasswordParam(SchemaBase):
@@ -55,8 +64,8 @@ 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: EmailStr = Field(examples=['user@example.com'], description='邮箱') email: CustomEmailStr | None = Field(None, description='邮箱')
phone: CustomPhoneNumber | None = Field(None, description='手机号') phone: CustomPhoneNumber | None = Field(None, description='手机号')
@@ -74,8 +83,7 @@ class GetUserInfoDetail(UserInfoSchemaBase):
dept_id: int | None = Field(None, description='部门 ID') dept_id: int | None = Field(None, description='部门 ID')
id: int = Field(description='用户 ID') id: int = Field(description='用户 ID')
uuid: str = Field(description='用户 UUID') uuid: str = Field(description='用户 UUID')
avatar: str | None = Field(None, description='头像') status: StatusType = Field(description='状态')
status: StatusType = Field(StatusType.enable, description='状态')
is_superuser: bool = Field(description='是否超级管理员') is_superuser: bool = Field(description='是否超级管理员')
is_staff: bool = Field(description='是否管理员') is_staff: bool = Field(description='是否管理员')
is_multi_login: bool = Field(description='是否允许多端登录') is_multi_login: bool = Field(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):
"""创建用户历史密码记录"""
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+172 -140
View File
@@ -1,17 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.security import HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTask, BackgroundTasks from starlette.background import BackgroundTask, BackgroundTasks
from backend.app.admin.crud.crud_menu import menu_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken from backend.app.admin.schema.token import GetLoginToken, GetNewToken
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.enums import LoginLogStatusType from backend.common.enums import LoginLogStatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.i18n import t
from backend.common.log import log from backend.common.log import log
from backend.common.response.response_code import CustomErrorCode from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import ( from backend.common.security.jwt import (
@@ -20,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 async_db_session, 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
@@ -32,7 +35,7 @@ class AuthService:
"""认证服务类""" """认证服务类"""
@staticmethod @staticmethod
async def user_verify(db: AsyncSession, username: str, password: str | None) -> User: async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
""" """
验证用户名和密码 验证用户名和密码
@@ -45,162 +48,198 @@ 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)
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='用户名或密码有误')
else:
if not password_verify(password, user.password):
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)
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]: return user, days_remaining
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
""" """
Swagger 文档登录 Swagger 文档登录
:param db: 数据库会话
:param obj: 登录凭证 :param obj: 登录凭证
:return: :return:
""" """
async with async_db_session.begin() as db: user, _ = await self.user_verify(db, obj.username, obj.password)
user = await self.user_verify(db, obj.username, obj.password) await user_dao.update_login_time(db, obj.username)
await user_dao.update_login_time(db, obj.username) access_token_data = await create_access_token(
a_token = await create_access_token( user.id,
str(user.id), multi_login=user.is_multi_login,
user.is_multi_login, # extra info
# extra info swagger=True,
swagger=True, )
) return access_token_data.access_token, user
return a_token.access_token, user
async def login( async def login(
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks self,
*,
db: AsyncSession,
response: Response,
obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> GetLoginToken: ) -> GetLoginToken:
""" """
用户登录 用户登录
:param request: 请求对象 :param db: 数据库会话
:param response: 响应对象 :param response: 响应对象
:param obj: 登录参数 :param obj: 登录参数
:param background_tasks: 后台任务 :param background_tasks: 后台任务
:return: :return:
""" """
async with async_db_session.begin() as db: user = None
user = None try:
try: user, days_remaining = await self.user_verify(db, obj.username, obj.password)
user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await load_login_config(db)
if settings.LOGIN_CAPTCHA_ENABLED:
if not obj.uuid or not obj.captcha:
raise errors.RequestError(msg=t('error.captcha.invalid'))
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
if not captcha_code: if not captcha_code:
raise errors.ForbiddenError(msg='验证码失效,请重新获取') raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower(): if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}')
await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await user_dao.update_login_time(db, obj.username)
a_token = await create_access_token( await db.refresh(user)
str(user.id), access_token_data = await create_access_token(
user.is_multi_login, user.id,
# extra info multi_login=user.is_multi_login,
username=user.username, # extra info
nickname=user.nickname, username=user.username,
last_login_time=timezone.t_str(user.last_login_time), nickname=user.nickname,
ip=request.state.ip, last_login_time=timezone.to_str(user.last_login_time),
os=request.state.os, ip=ctx.ip,
browser=request.state.browser, os=ctx.os,
device=request.state.device, browser=ctx.browser,
) device=ctx.device,
r_token = await create_refresh_token(str(user.id), user.is_multi_login) )
response.set_cookie( refresh_token_data = await create_refresh_token(
key=settings.COOKIE_REFRESH_TOKEN_KEY, access_token_data.session_uuid,
value=r_token.refresh_token, user.id,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS, multi_login=user.is_multi_login,
expires=timezone.f_utc(r_token.refresh_token_expire_time), )
httponly=True, response.set_cookie(
) key=settings.COOKIE_REFRESH_TOKEN_KEY,
except errors.NotFoundError as e: value=refresh_token_data.refresh_token,
log.error('登陆错误: 用户名不存在') max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
raise errors.NotFoundError(msg=e.msg) expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
except (errors.ForbiddenError, errors.CustomError) as e: httponly=True,
if not user: )
log.error('登陆错误: 用户密码有误') except errors.NotFoundError as e:
task = BackgroundTask( log.error('登陆错误: 用户名不存在')
login_log_service.create, raise errors.NotFoundError(msg=e.msg)
**dict( except (errors.RequestError, errors.CustomError) as e:
db=db, if not user:
request=request, log.error('登陆错误: 用户密码有误')
user_uuid=user.uuid if user else uuid4_str(), task = BackgroundTask(
username=obj.username, login_log_service.create,
login_time=timezone.now(), user_uuid=user.uuid if user else uuid4_str(),
status=LoginLogStatusType.fail.value, username=obj.username,
msg=e.msg, login_time=timezone.now(),
), status=LoginLogStatusType.fail.value,
) msg=e.msg,
raise errors.RequestError(msg=e.msg, background=task) )
except Exception as e: raise errors.RequestError(code=e.code, msg=e.msg, background=task)
log.error(f'登陆错误: {e}') except Exception as e:
raise e log.error(f'登陆错误: {e}')
else: raise
background_tasks.add_task( else:
login_log_service.create, background_tasks.add_task(
**dict( login_log_service.create,
db=db, user_uuid=user.uuid,
request=request, username=obj.username,
user_uuid=user.uuid, login_time=timezone.now(),
username=obj.username, status=LoginLogStatusType.success.value,
login_time=timezone.now(), msg=t('success.login.success'),
status=LoginLogStatusType.success.value, )
msg='登录成功', data = GetLoginToken(
), access_token=access_token_data.access_token,
) access_token_expire_time=access_token_data.access_token_expire_time,
data = GetLoginToken( session_uuid=access_token_data.session_uuid,
access_token=a_token.access_token, password_expire_days_remaining=days_remaining,
access_token_expire_time=a_token.access_token_expire_time, user=user, # type: ignore
session_uuid=a_token.session_uuid, )
user=user, # type: ignore return data
)
return data
@staticmethod @staticmethod
async def new_token(*, request: Request) -> GetNewToken: async def get_codes(*, db: AsyncSession, request: Request) -> list[str]:
""" """
获取新的访问令牌 获取用户权限码
:param db: 数据库会话
:param request: FastAPI 请求对象
:return:
"""
codes = set()
if request.user.is_superuser:
menus = await menu_dao.get_all(db, None, None)
for menu in menus:
if menu.perms:
codes.add(*menu.perms.split(','))
else:
roles = request.user.roles
if roles:
for role in roles:
for menu in role.menus:
if menu.perms:
codes.add(*menu.perms.split(','))
return list(codes)
@staticmethod
async def refresh_token(*, db: AsyncSession, request: Request) -> GetNewToken:
"""
刷新令牌
:param db: 数据库会话
:param request: FastAPI 请求对象 :param request: 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.TokenError(msg='Refresh Token 已过期,请重新登录') raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
try: token_payload = jwt_decode(refresh_token)
user_id = jwt_decode(refresh_token).id
except Exception: user = await user_dao.get(db, token_payload.id)
raise errors.TokenError(msg='Refresh Token 无效') if not user:
async with async_db_session() as db: raise errors.NotFoundError(msg='用户不存在')
user = await user_dao.get(db, user_id) if not user.status:
if not user: raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
raise errors.NotFoundError(msg='用户名或密码有误') if not user.is_multi_login and await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
elif not user.status: raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') new_token = await create_new_token(
new_token = await create_new_token( refresh_token,
user_id=str(user.id), token_payload.session_uuid,
refresh_token=refresh_token, user.id,
multi_login=user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
username=user.username, username=user.username,
nickname=user.nickname, nickname=user.nickname,
last_login_time=timezone.t_str(user.last_login_time), last_login_time=timezone.to_str(user.last_login_time),
ip=request.state.ip, ip=ctx.ip,
os=request.state.os, os=ctx.os,
browser=request.state.browser, browser=ctx.browser,
device_type=request.state.device, device_type=ctx.device,
) )
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,
session_uuid=new_token.session_uuid, session_uuid=new_token.session_uuid,
) )
return data return data
@staticmethod @staticmethod
async def logout(*, request: Request, response: Response) -> None: async def logout(*, request: Request, response: Response) -> None:
@@ -215,24 +254,17 @@ class AuthService:
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.id user_id = token_payload.id
session_uuid = token_payload.session_uuid
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY) refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
except errors.TokenError: except errors.TokenError:
return return
finally: finally:
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY) response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
# 清理缓存 await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
if request.user.is_multi_login: await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') if refresh_token:
if refresh_token: await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
else:
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
auth_service: AuthService = AuthService() auth_service: AuthService = AuthService()
+65 -47
View File
@@ -1,39 +1,47 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Table
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_rule import data_rule_dao from backend.app.admin.crud.crud_data_rule import data_rule_dao
from backend.app.admin.model import DataRule from backend.app.admin.model import DataRule
from backend.app.admin.schema.data_rule import CreateDataRuleParam, GetDataRuleColumnDetail, UpdateDataRuleParam from backend.app.admin.schema.data_rule import (
CreateDataRuleParam,
DeleteDataRuleParam,
GetDataRuleColumnDetail,
UpdateDataRuleParam,
)
from backend.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.security.permission import get_data_permission_models
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.utils.import_parse import dynamic_import_data_model
class DataRuleService: class DataRuleService:
"""数据规则服务类""" """数据规则服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> DataRule: async def get(*, db: AsyncSession, pk: int) -> DataRule:
""" """
获取数据规则详情 获取数据规则详情
:param db: 数据库会话
:param pk: 规则 ID :param pk: 规则 ID
:return: :return:
""" """
async with async_db_session() as db:
data_rule = await data_rule_dao.get(db, pk) data_rule = await data_rule_dao.get(db, pk)
if not data_rule: if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
return data_rule return data_rule
@staticmethod @staticmethod
async def get_models() -> list[str]: async def get_models() -> list[str]:
"""获取所有数据规则可用模型""" """获取所有数据规则可用模型"""
return list(settings.DATA_PERMISSION_MODELS.keys()) model_exclude = ['DataScope', 'DataRule', 'sys_role_data_scope', 'sys_data_scope_rule']
return [m for m in list(get_data_permission_models().keys()) if m not in model_exclude]
@staticmethod @staticmethod
async def get_columns(model: str) -> list[GetDataRuleColumnDetail]: async def get_columns(model: str) -> list[GetDataRuleColumnDetail]:
@@ -43,78 +51,88 @@ class DataRuleService:
:param model: 模型名称 :param model: 模型名称
:return: :return:
""" """
if model not in settings.DATA_PERMISSION_MODELS: available_models = get_data_permission_models()
if model not in available_models:
raise errors.NotFoundError(msg='数据规则可用模型不存在') raise errors.NotFoundError(msg='数据规则可用模型不存在')
model_ins = dynamic_import_data_model(settings.DATA_PERMISSION_MODELS[model]) 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
@staticmethod @staticmethod
async def get_select(*, name: str | None) -> Select: async def get_list(*, db: AsyncSession, name: str | None) -> dict[str, Any]:
""" """
获取数据规则列表查询条件 获取数据规则列表
:param db: 数据库会话
:param name: 规则名称 :param name: 规则名称
:return: :return:
""" """
return await data_rule_dao.get_list(name=name) data_rule_select = await data_rule_dao.get_select(name=name)
return await paging_data(db, data_rule_select)
@staticmethod @staticmethod
async def get_all() -> Sequence[DataRule]: async def get_all(*, db: AsyncSession) -> Sequence[DataRule]:
"""获取所有数据规则""" """
async with async_db_session() as db: 获取所有数据规则
data_rules = await data_rule_dao.get_all(db)
return data_rules :param db: 数据库会话
:return:
"""
data_rules = await data_rule_dao.get_all(db)
return data_rules
@staticmethod @staticmethod
async def create(*, obj: CreateDataRuleParam) -> None: async def create(*, db: AsyncSession, obj: CreateDataRuleParam) -> None:
""" """
创建数据规则 创建数据规则
:param db: 数据库会话
:param obj: 规则创建参数 :param obj: 规则创建参数
:return: :return:
""" """
async with async_db_session.begin() as db: data_rule = await data_rule_dao.get_by_name(db, obj.name)
data_rule = await data_rule_dao.get_by_name(db, obj.name) if data_rule:
if data_rule: raise errors.ConflictError(msg='数据规则已存在')
raise errors.ForbiddenError(msg='数据规则已存在') await data_rule_dao.create(db, obj)
await data_rule_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDataRuleParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateDataRuleParam) -> int:
""" """
更新数据规则 更新数据规则
:param db: 数据库会话
:param pk: 规则 ID :param pk: 规则 ID
:param obj: 规则更新参数 :param obj: 规则更新参数
:return: :return:
""" """
async with async_db_session.begin() as db: data_rule = await data_rule_dao.get(db, pk)
data_rule = await data_rule_dao.get(db, pk) if not data_rule:
if not data_rule: raise errors.NotFoundError(msg='数据规则不存在')
raise errors.NotFoundError(msg='数据规则不存在') if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
if data_rule.name != obj.name: raise errors.ConflictError(msg='数据规则已存在')
if await data_rule_dao.get_by_name(db, obj.name): count = await data_rule_dao.update(db, pk, obj)
raise errors.ForbiddenError(msg='数据规则已存在') await user_cache_manager.clear_by_data_rule_id(db, [pk])
count = await data_rule_dao.update(db, pk, obj) return count
return count
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, db: AsyncSession, obj: DeleteDataRuleParam) -> int:
""" """
删除数据规则 批量删除数据规则
:param pk: 规则 ID 列表 :param db: 数据库会话
:param obj: 规则 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: count = await data_rule_dao.delete(db, obj.pks)
count = await data_rule_dao.delete(db, pk) await user_cache_manager.clear_by_data_rule_id(db, obj.pks)
return count return count
data_rule_service: DataRuleService = DataRuleService() data_rule_service: DataRuleService = DataRuleService()
+71 -57
View File
@@ -1,124 +1,138 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.model import DataScope from backend.app.admin.model import DataScope
from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam from backend.app.admin.schema.data_scope import (
CreateDataScopeParam,
DeleteDataScopeParam,
UpdateDataScopeParam,
UpdateDataScopeRuleParam,
)
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.common.pagination import paging_data
from backend.database.db import async_db_session
from backend.database.redis import redis_client
class DataScopeService: class DataScopeService:
"""数据范围服务类""" """数据范围服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> DataScope: async def get(*, db: AsyncSession, pk: int) -> DataScope:
""" """
获取数据范围详情 获取数据范围详情
:param db: 数据库会话
:param pk: 范围 ID :param pk: 范围 ID
:return: :return:
""" """
async with async_db_session() as db:
data_scope = await data_scope_dao.get(db, pk) data_scope = await data_scope_dao.get(db, pk)
if not data_scope: if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
return data_scope return data_scope
@staticmethod @staticmethod
async def get_rules(*, pk: int) -> DataScope: async def get_all(*, db: AsyncSession) -> Sequence[DataScope]:
"""
获取所有数据范围
:param db: 数据库会话
:return:
"""
data_scopes = await data_scope_dao.get_all(db)
return data_scopes
@staticmethod
async def get_rules(*, db: AsyncSession, pk: int) -> DataScope:
""" """
获取数据范围规则 获取数据范围规则
:param db: 数据库会话
:param pk: 范围 ID :param pk: 范围 ID
:return: :return:
""" """
async with async_db_session() as db:
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
@staticmethod @staticmethod
async def get_select(*, name: str | None, status: int | None) -> Select: async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
""" """
获取数据范围列表查询条件 获取数据范围列表
:param db: 数据库会话
:param name: 范围名称 :param name: 范围名称
:param status: 范围状态 :param status: 范围状态
:return: :return:
""" """
return await data_scope_dao.get_list(name, status) data_scope_select = await data_scope_dao.get_select(name, status)
return await paging_data(db, data_scope_select)
@staticmethod @staticmethod
async def create(*, obj: CreateDataScopeParam) -> None: async def create(*, db: AsyncSession, obj: CreateDataScopeParam) -> None:
""" """
创建数据范围 创建数据范围
:param db: 数据库会话
:param obj: 数据范围参数 :param obj: 数据范围参数
:return: :return:
""" """
async with async_db_session.begin() as db: data_scope = await data_scope_dao.get_by_name(db, obj.name)
data_scope = await data_scope_dao.get_by_name(db, obj.name) if data_scope:
if data_scope: raise errors.ConflictError(msg='数据范围已存在')
raise errors.ForbiddenError(msg='数据范围已存在') await data_scope_dao.create(db, obj)
await data_scope_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDataScopeParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int:
""" """
更新数据范围 更新数据范围
:param db: 数据库会话
:param pk: 范围 ID :param pk: 范围 ID
:param obj: 数据范围更新参数 :param obj: 数据范围更新参数
:return: :return:
""" """
async with async_db_session.begin() as db: data_scope = await data_scope_dao.get(db, pk)
data_scope = await data_scope_dao.get(db, pk) if not data_scope:
if not data_scope: raise errors.NotFoundError(msg='数据范围不存在')
raise errors.NotFoundError(msg='数据范围不存在') if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
if data_scope.name != obj.name: raise errors.ConflictError(msg='数据范围已存在')
if await data_scope_dao.get_by_name(db, obj.name): count = await data_scope_dao.update(db, pk, obj)
raise errors.ForbiddenError(msg='数据范围已存在') await user_cache_manager.clear_by_data_scope_id(db, [pk])
count = await data_scope_dao.update(db, pk, obj) return count
for role in await data_scope.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def update_data_scope_rule(*, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int: async def update_data_scope_rule(*, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int:
""" """
更新数据范围规则 更新数据范围规则
:param db: 数据库会话
:param pk: 范围 ID :param pk: 范围 ID
:param rule_ids: 规则 ID 列表 :param rule_ids: 规则 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: 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
async def delete(*, pk: list[int]) -> int: async def delete(*, db: AsyncSession, obj: DeleteDataScopeParam) -> int:
""" """
删除数据范围 批量删除数据范围
:param pk: 范围 ID 列表 :param db: 数据库会话
:param obj: 范围 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: count = await data_scope_dao.delete(db, obj.pks)
count = await data_scope_dao.delete(db, pk) await user_cache_manager.clear_by_data_scope_id(db, obj.pks)
for _pk in pk: return count
data_rule = await data_scope_dao.get(db, _pk)
if data_rule:
for role in await data_rule.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
data_scope_service: DataScopeService = DataScopeService() data_scope_service: DataScopeService = DataScopeService()
+61 -55
View File
@@ -1,15 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request from sqlalchemy import ColumnElement
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.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data from backend.utils.build_tree import get_tree_data
@@ -18,100 +16,108 @@ class DeptService:
"""部门服务类""" """部门服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Dept: async def get(*, db: AsyncSession, pk: int) -> Dept:
""" """
获取部门详情 获取部门详情
:param db: 数据库会话
:param pk: 部门 ID :param pk: 部门 ID
:return: :return:
""" """
async with async_db_session() as db:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
raise errors.NotFoundError(msg='部门不存在') raise errors.NotFoundError(msg='部门不存在')
return dept return dept
@staticmethod @staticmethod
async def get_dept_tree( async def get_tree(
*, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None *,
db: AsyncSession,
data_filter: ColumnElement[bool],
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
获取部门树形结构 获取部门树形结构
:param request: FastAPI 请求对象 :param db: 数据库会话
:param data_filter: 请求用户
:param name: 部门名称 :param name: 部门名称
:param leader: 部门负责人 :param leader: 部门负责人
:param phone: 联系电话 :param phone: 联系电话
:param status: 状态 :param status: 状态
:return: :return:
""" """
async with async_db_session() as db: 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
@staticmethod @staticmethod
async def create(*, obj: CreateDeptParam) -> None: async def create(*, db: AsyncSession, obj: CreateDeptParam) -> None:
""" """
创建部门 创建部门
:param db: 数据库会话
:param obj: 部门创建参数 :param obj: 部门创建参数
:return: :return:
""" """
async with async_db_session.begin() as db: dept = await dept_dao.get_by_name(db, obj.name)
dept = await dept_dao.get_by_name(db, obj.name) if dept:
if dept: raise errors.ConflictError(msg='部门名称已存在')
raise errors.ForbiddenError(msg='部门名称已存在') if obj.parent_id is not None:
if obj.parent_id: parent_dept = await dept_dao.get(db, obj.parent_id)
parent_dept = await dept_dao.get(db, obj.parent_id) if not parent_dept:
if not parent_dept: raise errors.NotFoundError(msg='父级部门不存在')
raise errors.NotFoundError(msg='父级部门不存在') await dept_dao.create(db, obj)
await dept_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateDeptParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateDeptParam) -> int:
""" """
更新部门 更新部门
:param db: 数据库会话
:param pk: 部门 ID :param pk: 部门 ID
:param obj: 部门更新参数 :param obj: 部门更新参数
:return: :return:
""" """
async with async_db_session.begin() as db: dept = await dept_dao.get(db, pk)
dept = await dept_dao.get(db, pk) if not dept:
if not dept: raise errors.NotFoundError(msg='部门不存在')
raise errors.NotFoundError(msg='部门不存在') if dept.name != obj.name and await dept_dao.get_by_name(db, obj.name):
if dept.name != obj.name: raise errors.ConflictError(msg='部门名称已存在')
if await dept_dao.get_by_name(db, obj.name): if obj.parent_id:
raise errors.ForbiddenError(msg='部门名称已存在') parent_dept = await dept_dao.get(db, obj.parent_id)
if obj.parent_id: if not parent_dept:
parent_dept = await dept_dao.get(db, obj.parent_id) raise errors.NotFoundError(msg='父级部门不存在')
if not parent_dept: if obj.parent_id == dept.id:
raise errors.NotFoundError(msg='父级部门不存在') raise errors.ForbiddenError(msg='禁止关联自身为父级')
if obj.parent_id == dept.id: count = await dept_dao.update(db, pk, obj)
raise errors.ForbiddenError(msg='禁止关联自身为父级') return count
count = await dept_dao.update(db, pk, obj)
return count
@staticmethod @staticmethod
async def delete(*, pk: int) -> int: async def delete(*, db: AsyncSession, pk: int) -> int:
""" """
删除部门 删除部门
:param db: 数据库会话
:param pk: 部门 ID :param pk: 部门 ID
:return: :return:
""" """
async with async_db_session.begin() as db: dept = await dept_dao.get_join(db, pk)
dept = await dept_dao.get_with_relation(db, pk) if not dept:
if dept.users: raise errors.NotFoundError(msg='部门不存在')
raise errors.ForbiddenError(msg='部门下存在用户,无法删除') if dept.users:
children = await dept_dao.get_children(db, pk) raise errors.ConflictError(msg='部门下存在用户,无法删除')
if children: children = await dept_dao.get_children(db, pk)
raise errors.ForbiddenError(msg='部门下存在子部门,无法删除') if children:
count = await dept_dao.delete(db, pk) raise errors.ConflictError(msg='部门下存在子部门,无法删除')
for user in dept.users: count = await dept_dao.delete(db, pk)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') for user in dept.users:
return count await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
dept_service: DeptService = DeptService() dept_service: DeptService = DeptService()
+28 -31
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any
from fastapi import Request
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_login_log import login_log_dao from backend.app.admin.crud.crud_login_log import login_log_dao
from backend.app.admin.schema.login_log import CreateLoginLogParam from backend.app.admin.schema.login_log import CreateLoginLogParam, DeleteLoginLogParam
from backend.common.context import ctx
from backend.common.log import log from backend.common.log import log
from backend.common.pagination import paging_data
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -16,22 +15,22 @@ class LoginLogService:
"""登录日志服务类""" """登录日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select: async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
""" """
获取登录日志列表查询条件 获取登录日志列表
:param db: 数据库会话
:param username: 用户名 :param username: 用户名
:param status: 状态 :param status: 状态
:param ip: IP 地址 :param ip: IP 地址
:return: :return:
""" """
return await login_log_dao.get_list(username=username, status=status, ip=ip) log_select = await login_log_dao.get_select(username=username, status=status, ip=ip)
return await paging_data(db, log_select)
@staticmethod @staticmethod
async def create( async def create(
*, *,
db: AsyncSession,
request: Request,
user_uuid: str, user_uuid: str,
username: str, username: str,
login_time: datetime, login_time: datetime,
@@ -41,8 +40,6 @@ class LoginLogService:
""" """
创建登录日志 创建登录日志
:param db: 数据库会话
:param request: FastAPI 请求对象
:param user_uuid: 用户 UUID :param user_uuid: 用户 UUID
:param username: 用户名 :param username: 用户名
:param login_time: 登录时间 :param login_time: 登录时间
@@ -55,39 +52,39 @@ class LoginLogService:
user_uuid=user_uuid, user_uuid=user_uuid,
username=username, username=username,
status=status, status=status,
ip=request.state.ip, ip=ctx.ip,
country=request.state.country, country=ctx.country,
region=request.state.region, region=ctx.region,
city=request.state.city, city=ctx.city,
user_agent=request.state.user_agent, user_agent=ctx.user_agent,
browser=request.state.browser, browser=ctx.browser,
os=request.state.os, os=ctx.os,
device=request.state.device, device=ctx.device,
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}')
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def delete(*, db: AsyncSession, obj: DeleteLoginLogParam) -> int:
""" """
删除登录日志 批量删除登录日志
:param pk: 日志 ID 列表 :param db: 数据库会话
:param obj: 日志 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db: count = await login_log_dao.delete(db, obj.pks)
count = await login_log_dao.delete(db, pk) return count
return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete_all(*, db: AsyncSession) -> None:
"""清空所有登录日志""" """清空所有登录日志"""
async with async_db_session.begin() as db: await login_log_dao.delete_all(db)
count = await login_log_dao.delete_all(db)
return count
login_log_service: LoginLogService = LoginLogService() login_log_service: LoginLogService = LoginLogService()
+67 -72
View File
@@ -1,16 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
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.exception import errors from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.build_tree import get_tree_data, get_vben5_tree_data from backend.utils.build_tree import get_tree_data, get_vben5_tree_data
@@ -18,124 +15,122 @@ class MenuService:
"""菜单服务类""" """菜单服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Menu: async def get(*, db: AsyncSession, pk: int) -> Menu:
""" """
获取菜单详情 获取菜单详情
:param db: 数据库会话
:param pk: 菜单 ID :param pk: 菜单 ID
:return: :return:
""" """
async with async_db_session() as db:
menu = await menu_dao.get(db, menu_id=pk) menu = await menu_dao.get(db, menu_id=pk)
if not menu: if not menu:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
return menu return menu
@staticmethod @staticmethod
async def get_menu_tree(*, title: str | None, status: int | None) -> list[dict[str, Any]]: async def get_tree(*, db: AsyncSession, title: str | None, status: int | None) -> list[dict[str, Any]]:
""" """
获取菜单树形结构 获取菜单树形结构
:param db: 数据库会话
:param title: 菜单标题 :param title: 菜单标题
:param status: 状态 :param status: 状态
:return: :return:
""" """
async with async_db_session() as db:
menu_data = await menu_dao.get_all(db, title=title, status=status) menu_data = await menu_dao.get_all(db, title=title, status=status)
menu_tree = get_tree_data(menu_data) menu_tree = get_tree_data(menu_data)
return menu_tree return menu_tree
@staticmethod @staticmethod
async def get_sidebar(*, request: Request) -> list[dict[str, Any] | None]: async def get_sidebar(*, db: AsyncSession, request: Request) -> list[dict[str, Any] | None]:
""" """
获取用户的菜单侧边栏 获取用户的菜单侧边栏
:param db: 数据库会话
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
async with async_db_session() as db: menu_data = None
if request.user.is_superuser:
menu_data = await menu_dao.get_sidebar(db, None)
else:
roles = request.user.roles roles = request.user.roles
menu_tree = [] menu_ids = set()
if roles: if roles:
unique_menus = {}
for role in roles: for role in roles:
for menu in role.menus: menu_ids.update(menu.id for menu in role.menus)
unique_menus[menu.id] = menu menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
all_ids = set(unique_menus.keys())
valid_menu_ids = [ if menu_data:
menu_id return get_vben5_tree_data(menu_data)
for menu_id, menu in unique_menus.items()
if menu.parent_id is None or menu.parent_id in all_ids return []
]
menu_data = await menu_dao.get_sidebar(db, request.user.is_superuser, valid_menu_ids)
menu_tree = get_vben5_tree_data(menu_data)
return menu_tree
@staticmethod @staticmethod
async def create(*, obj: CreateMenuParam) -> None: async def create(*, db: AsyncSession, obj: CreateMenuParam) -> None:
""" """
创建菜单 创建菜单
:param db: 数据库会话
:param obj: 菜单创建参数 :param obj: 菜单创建参数
:return: :return:
""" """
async with async_db_session.begin() as db:
title = await menu_dao.get_by_title(db, obj.title) title = await menu_dao.get_by_title(db, obj.title)
if title: if title:
raise errors.ForbiddenError(msg='菜单标题已存在') raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id: if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id) parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu: if not parent_menu:
raise errors.NotFoundError(msg='父级菜单不存在') raise errors.NotFoundError(msg='父级菜单不存在')
await menu_dao.create(db, obj) await menu_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateMenuParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateMenuParam) -> int:
""" """
更新菜单 更新菜单
:param db: 数据库会话
:param pk: 菜单 ID :param pk: 菜单 ID
:param obj: 菜单更新参数 :param obj: 菜单更新参数
:return: :return:
""" """
async with async_db_session.begin() as db:
menu = await menu_dao.get(db, pk) menu = await menu_dao.get(db, pk)
if not menu: if not menu:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title: if menu.title != obj.title and await menu_dao.get_by_title(db, obj.title):
if await menu_dao.get_by_title(db, obj.title): raise errors.ConflictError(msg='菜单标题已存在')
raise errors.ForbiddenError(msg='菜单标题已存在') if obj.parent_id:
if obj.parent_id: parent_menu = await menu_dao.get(db, obj.parent_id)
parent_menu = await menu_dao.get(db, obj.parent_id) if not parent_menu:
if not parent_menu: raise errors.NotFoundError(msg='父级菜单不存在')
raise errors.NotFoundError(msg='父级菜单不存在') 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) await user_cache_manager.clear_by_menu_id(db, [pk])
for role in await menu.awaitable_attrs.roles: return count
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def delete(*, pk: int) -> int: async def delete(*, db: AsyncSession, pk: int) -> int:
""" """
删除菜单 删除菜单
:param db: 数据库会话
:param pk: 菜单 ID :param pk: 菜单 ID
:return: :return:
""" """
async with async_db_session.begin() as db:
children = await menu_dao.get_children(db, pk) children = await menu_dao.get_children(db, pk)
if children: if children:
raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除') raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk) count = await menu_dao.delete(db, pk)
count = await menu_dao.delete(db, pk) if count:
if menu: await user_cache_manager.clear_by_menu_id(db, [pk])
for role in await menu.awaitable_attrs.roles: return count
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
menu_service: MenuService = MenuService() menu_service: MenuService = MenuService()
+38 -22
View File
@@ -1,56 +1,72 @@
#!/usr/bin/env python3 from typing import Any
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_opera_log import opera_log_dao from backend.app.admin.crud.crud_opera_log import opera_log_dao
from backend.app.admin.schema.opera_log import CreateOperaLogParam from backend.app.admin.schema.opera_log import CreateOperaLogParam, DeleteOperaLogParam
from backend.database.db import async_db_session from backend.common.pagination import paging_data
class OperaLogService: class OperaLogService:
"""操作日志服务类""" """操作日志服务类"""
@staticmethod @staticmethod
async def get_select(*, username: str | None, status: int | None, ip: str | None) -> Select: async def get_list(*, db: AsyncSession, username: str | None, status: int | None, ip: str | None) -> dict[str, Any]:
""" """
获取操作日志列表查询条件 获取操作日志列表
:param db: 数据库会话
:param username: 用户名 :param username: 用户名
:param status: 状态 :param status: 状态
:param ip: IP 地址 :param ip: IP 地址
:return: :return:
""" """
return await opera_log_dao.get_list(username=username, status=status, ip=ip) log_select = await opera_log_dao.get_select(username=username, status=status, ip=ip)
return await paging_data(db, log_select)
@staticmethod @staticmethod
async def create(*, obj: CreateOperaLogParam) -> None: async def create(*, db: AsyncSession, obj: CreateOperaLogParam) -> None:
""" """
创建操作日志 创建操作日志
:param db: 数据库会话
:param obj: 操作日志创建参数 :param obj: 操作日志创建参数
:return: :return:
""" """
async with async_db_session.begin() as db: await opera_log_dao.create(db, obj)
await opera_log_dao.create(db, obj)
@staticmethod @staticmethod
async def delete(*, pk: list[int]) -> int: async def bulk_create(*, db: AsyncSession, objs: list[CreateOperaLogParam]) -> None:
""" """
删除操作日志 批量创建操作日志
:param pk: 日志 ID 列表 :param db: 数据库会话
:param objs: 操作日志创建参数列表
:return: :return:
""" """
async with async_db_session.begin() as db: await opera_log_dao.bulk_create(db, objs)
count = await opera_log_dao.delete(db, pk)
return count
@staticmethod @staticmethod
async def delete_all() -> int: async def delete(*, db: AsyncSession, obj: DeleteOperaLogParam) -> int:
"""清空所有操作日志""" """
async with async_db_session.begin() as db: 批量删除操作日志
count = await opera_log_dao.delete_all(db)
return count :param db: 数据库会话
:param obj: 日志 ID 列表
:return:
"""
count = await opera_log_dao.delete(db, obj.pks)
return count
@staticmethod
async def delete_all(*, db: AsyncSession) -> None:
"""
清空所有操作日志
:param db: 数据库会话
:return:
"""
await opera_log_dao.delete_all(db)
opera_log_service: OperaLogService = OperaLogService() opera_log_service: OperaLogService = OperaLogService()
+37 -94
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io import io
import json import json
import os import os
@@ -8,17 +6,17 @@ import zipfile
from typing import Any from typing import Any
from dulwich import porcelain import anyio
from fastapi import UploadFile from fastapi import UploadFile
from backend.common.enums import StatusType from backend.common.enums import PluginType, StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.log import log
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 install_requirements_async, uninstall_requirements_async from backend.plugin.tools import uninstall_requirements_async
from backend.utils.re_verify import is_git_url from backend.utils.file_ops import install_git_plugin, install_zip_plugin
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -28,120 +26,68 @@ class PluginService:
@staticmethod @staticmethod
async def get_all() -> list[dict[str, Any]]: async def get_all() -> list[dict[str, Any]]:
"""获取所有插件""" """获取所有插件"""
keys = []
result = []
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'): keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*')]
keys.append(key)
for info in await redis_client.mget(*keys): result = [json.loads(info) for info in await redis_client.mget(*keys)]
result.append(json.loads(info))
return result return result
@staticmethod @staticmethod
async def changed() -> str | None: async def changed() -> str | None:
"""插件状态是否变更""" """检查插件是否发生变更"""
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed') return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
@staticmethod @staticmethod
async def install_zip(*, file: UploadFile) -> None: async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None) -> str:
""" """
通过 zip 压缩包安装插件 安装插件
:param type: 插件类型
:param file: 插件 zip 压缩包 :param file: 插件 zip 压缩包
:param repo_url: git 仓库地址
:return: :return:
""" """
contents = await file.read() if settings.ENVIRONMENT != 'dev':
file_bytes = io.BytesIO(contents) raise errors.RequestError(msg='禁止在非开发环境下安装插件')
if not zipfile.is_zipfile(file_bytes): if type == PluginType.zip:
raise errors.ForbiddenError(msg='插件压缩包格式非法') if not file:
with zipfile.ZipFile(file_bytes) as zf: raise errors.RequestError(msg='ZIP 压缩包不能为空')
# 校验压缩包 return await install_zip_plugin(file)
plugin_namelist = zf.namelist() if not repo_url:
plugin_name = plugin_namelist[0].split('/')[0] raise errors.RequestError(msg='Git 仓库地址不能为空')
if not plugin_namelist or plugin_name not in file.filename: return await install_git_plugin(repo_url)
raise errors.ForbiddenError(msg='插件压缩包内容非法')
if (
len(plugin_namelist) <= 3
or f'{plugin_name}/plugin.toml' not in plugin_namelist
or f'{plugin_name}/README.md' not in plugin_namelist
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
# 解压(安装)
members = []
for member in zf.infolist():
if member.filename.startswith(plugin_name):
new_filename = member.filename.replace(plugin_name, '')
if new_filename:
member.filename = new_filename
members.append(member)
zf.extractall(os.path.join(PLUGIN_DIR, plugin_name), members)
await install_requirements_async(plugin_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod @staticmethod
async def install_git(*, repo_url: str): async def uninstall(*, plugin: str) -> None:
"""
通过 git 安装插件
:param repo_url: git 存储库的 URL
:return:
"""
match = is_git_url(repo_url)
if not match:
raise errors.ForbiddenError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
if repo_name in plugins:
raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
except Exception as e:
log.error(f'插件安装失败: {e}')
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
else:
await install_requirements_async(repo_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod
async def uninstall(*, plugin: str):
""" """
卸载插件 卸载插件
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
plugin_dir = os.path.join(PLUGIN_DIR, plugin) if settings.ENVIRONMENT != 'dev':
if not os.path.exists(plugin_dir): raise errors.RequestError(msg='禁止在非开发环境下卸载插件')
raise errors.ForbiddenError(msg='插件不存在') plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin) await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup'
shutil.move(plugin_dir, bacup_dir) shutil.move(plugin_dir, bacup_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}') await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
await redis_client.hdel(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod @staticmethod
async def update_status(*, plugin: str): async def update_status(*, plugin: str) -> None:
""" """
更新插件状态 更新插件状态
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}') plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
if not plugin_info: if not plugin_info:
raise errors.ForbiddenError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
plugin_info = json.loads(plugin_info) plugin_info = json.loads(plugin_info)
# 更新持久缓存状态 # 更新持久缓存状态
@@ -151,10 +97,7 @@ 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( await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}', json.dumps(plugin_info, ensure_ascii=False))
f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False)
)
await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, new_status)
@staticmethod @staticmethod
async def build(*, plugin: str) -> io.BytesIO: async def build(*, plugin: str) -> io.BytesIO:
@@ -164,9 +107,9 @@ class PluginService:
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
plugin_dir = os.path.join(PLUGIN_DIR, plugin) plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not os.path.exists(plugin_dir): if not await plugin_dir.exists():
raise errors.ForbiddenError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO() bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf: with zipfile.ZipFile(bio, 'w') as zf:
@@ -174,7 +117,7 @@ class PluginService:
dirs[:] = [d for d in dirs if d != '__pycache__'] dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files: for file in files:
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir) arcname = os.path.relpath(file_path, start=plugin_dir) # noqa: ASYNC240
zf.write(file_path, os.path.join(plugin, arcname)) zf.write(file_path, os.path.join(plugin, arcname))
bio.seek(0) bio.seek(0)
+93 -99
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from typing import Any, Sequence
from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_data_scope import data_scope_dao from backend.app.admin.crud.crud_data_scope import data_scope_dao
from backend.app.admin.crud.crud_menu import menu_dao from backend.app.admin.crud.crud_menu import menu_dao
@@ -10,14 +9,14 @@ from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.model import Role from backend.app.admin.model import Role
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
DeleteRoleParam,
UpdateRoleMenuParam, UpdateRoleMenuParam,
UpdateRoleParam, UpdateRoleParam,
UpdateRoleScopeParam, UpdateRoleScopeParam,
) )
from backend.app.admin.utils.cache import user_cache_manager
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.common.pagination import paging_data
from backend.database.db import async_db_session
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
@@ -25,175 +24,170 @@ class RoleService:
"""角色服务类""" """角色服务类"""
@staticmethod @staticmethod
async def get(*, pk: int) -> Role: async def get(*, db: AsyncSession, pk: int) -> Role:
""" """
获取角色详情 获取角色详情
:param db: 数据库会话
:param pk: 角色 ID :param pk: 角色 ID
:return: :return:
""" """
async with async_db_session() as db:
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
@staticmethod @staticmethod
async def get_all() -> Sequence[Role]: async def get_all(*, db: AsyncSession) -> Sequence[Role]:
"""获取所有角色"""
async with async_db_session() as db:
roles = await role_dao.get_all(db)
return roles
@staticmethod
async def get_users(*, pk: int) -> Sequence[Role]:
""" """
获取用户的角色列表 获取所有角色
:param pk: 用户 ID :param db: 数据库会话
:return: :return:
""" """
async with async_db_session() as db:
roles = await role_dao.get_users(db, user_id=pk) roles = await role_dao.get_all(db)
return roles return roles
@staticmethod @staticmethod
async def get_select(*, name: str | None, status: int | None) -> Select: async def get_list(*, db: AsyncSession, name: str | None, status: int | None) -> dict[str, Any]:
""" """
获取角色列表查询条件 获取角色列表
:param db: 数据库会话
:param name: 角色名称 :param name: 角色名称
:param status: 状态 :param status: 状态
:return: :return:
""" """
return await role_dao.get_list(name=name, status=status) role_select = await role_dao.get_select(name=name, status=status)
return await paging_data(db, role_select)
@staticmethod @staticmethod
async def get_menu_tree(*, pk: int) -> list[dict[str, Any] | None]: async def get_menu_tree(*, db: AsyncSession, pk: int) -> list[dict[str, Any] | None]:
""" """
获取角色的菜单树形结构 获取角色的菜单树形结构
:param db: 数据库会话
:param pk: 角色 ID :param pk: 角色 ID
:return: :return:
""" """
async with async_db_session() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
menu_tree = get_tree_data(role.menus) if role.menus else [] menus = await role_dao.get_menus(db, pk)
return menu_tree menu_tree = get_tree_data(menus) if menus else []
return menu_tree
@staticmethod @staticmethod
async def get_scopes(*, pk: int) -> list[int]: async def get_scopes(*, db: AsyncSession, pk: int) -> list[int]:
""" """
获取角色数据范围列表 获取角色数据范围列表
:param db: 数据库会话
:param pk: :param pk:
:return: :return:
""" """
async with async_db_session() as db:
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]
return scope_ids return scope_ids
@staticmethod @staticmethod
async def create(*, obj: CreateRoleParam) -> None: async def create(*, db: AsyncSession, obj: CreateRoleParam) -> None:
""" """
创建角色 创建角色
:param db: 数据库会话
:param obj: 角色创建参数 :param obj: 角色创建参数
:return: :return:
""" """
async with async_db_session.begin() as db:
role = await role_dao.get_by_name(db, obj.name) role = await role_dao.get_by_name(db, obj.name)
if role: if role:
raise errors.ForbiddenError(msg='角色已存在') raise errors.ConflictError(msg='角色已存在')
await role_dao.create(db, obj) await role_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, pk: int, obj: UpdateRoleParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateRoleParam) -> int:
""" """
更新角色 更新角色
:param db: 数据库会话
:param pk: 角色 ID :param pk: 角色 ID
:param obj: 角色更新参数 :param obj: 角色更新参数
:return: :return:
""" """
async with async_db_session.begin() as db:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name: if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
role = await role_dao.get_by_name(db, obj.name) raise errors.ConflictError(msg='角色已存在')
if role: count = await role_dao.update(db, pk, obj)
raise errors.ForbiddenError(msg='角色已存在') await user_cache_manager.clear_by_role_id(db, [pk])
count = await role_dao.update(db, pk, obj) return count
for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def update_role_menu(*, pk: int, menu_ids: UpdateRoleMenuParam) -> int: async def update_role_menu(*, db: AsyncSession, pk: int, menu_ids: UpdateRoleMenuParam) -> int:
""" """
更新角色菜单 更新角色菜单
:param db: 数据库会话
:param pk: 角色 ID :param pk: 角色 ID
:param menu_ids: 菜单 ID 列表 :param menu_ids: 菜单 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db:
role = await role_dao.get_with_relation(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
for menu_id in menu_ids.menus: for menu_id in menu_ids.menus:
menu = await menu_dao.get(db, menu_id) menu = await menu_dao.get(db, menu_id)
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
async def update_role_scope(*, pk: int, scope_ids: UpdateRoleScopeParam) -> int: async def update_role_scope(*, db: AsyncSession, pk: int, scope_ids: UpdateRoleScopeParam) -> int:
""" """
更新角色数据范围 更新角色数据范围
:param db: 数据库会话
:param pk: 角色 ID :param pk: 角色 ID
:param scope_ids: 权限规则 ID 列表 :param scope_ids: 权限规则 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
for scope_id in scope_ids.scopes: for scope_id in scope_ids.scopes:
scope = await data_scope_dao.get(db, scope_id) scope = await data_scope_dao.get(db, scope_id)
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
async def delete(*, pk: list[int]) -> int: async def delete(*, db: AsyncSession, obj: DeleteRoleParam) -> int:
""" """
删除角色 批量删除角色
:param pk: 角色 ID 列表 :param db: 数据库会话
:param obj: 角色 ID 列表
:return: :return:
""" """
async with async_db_session.begin() as db:
count = await role_dao.delete(db, pk) count = await role_dao.delete(db, obj.pks)
for _pk in pk: await user_cache_manager.clear_by_role_id(db, obj.pks)
role = await role_dao.get(db, _pk) return count
if role:
for user in await role.awaitable_attrs.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
role_service: RoleService = RoleService() role_service: RoleService = RoleService()
@@ -0,0 +1,118 @@
import math
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.timezone import timezone
class UserPasswordHistoryService:
"""用户密码历史服务类"""
@staticmethod
async def check_status(user_id: int, user_status: int) -> None:
"""
检查用户状态
:param user_id: 用户 ID
:param user_status: 用户状态
:return:
"""
if not user_status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
if locked_until_str:
locked_until = timezone.from_str(locked_until_str)
now = timezone.now()
if locked_until > now:
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod
async def handle_login_failure(db: AsyncSession, user_id: int) -> None:
"""
处理登录失败
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
await load_user_security_config(db)
if settings.USER_LOCK_THRESHOLD == 0:
return
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
failure_count = int(failure_count) if failure_count else 0
failure_count += 1
await redis_client.set(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}', str(failure_count))
if failure_count >= settings.USER_LOCK_THRESHOLD:
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
await redis_client.set(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}', timezone.to_str(locked_until))
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()
+244 -210
View File
@@ -1,291 +1,325 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import random import random
from collections.abc import Sequence
from typing import Any
from fastapi import Request from fastapi import Request
from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_dept import dept_dao from backend.app.admin.crud.crud_dept import dept_dao
from backend.app.admin.crud.crud_role import role_dao from backend.app.admin.crud.crud_role import role_dao
from backend.app.admin.crud.crud_user import user_dao from backend.app.admin.crud.crud_user import user_dao
from backend.app.admin.model import User from backend.app.admin.model import Role, User
from backend.app.admin.schema.user import ( from backend.app.admin.schema.user import (
AddUserParam, AddUserParam,
RegisterUserParam,
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.enums import UserPermissionType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.security.jwt import get_hash_password, get_token, jwt_decode, password_verify, superuser_verify from backend.common.pagination import paging_data
from backend.common.response.response_code import CustomErrorCode
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.db import async_db_session
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize
class UserService: class UserService:
"""用户服务类""" """用户服务类"""
@staticmethod @staticmethod
async def register(*, obj: RegisterUserParam) -> None: async def get_userinfo(*, db: AsyncSession, pk: int | None = None, username: str | None = None) -> User:
"""
注册新用户
:param obj: 用户注册参数
:return:
"""
async with async_db_session.begin() as db:
if not obj.password:
raise errors.ForbiddenError(msg='密码为空')
username = await user_dao.get_by_username(db, obj.username)
if username:
raise errors.ForbiddenError(msg='用户已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(10000, 88888)}'
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
await user_dao.create(db, obj)
@staticmethod
async def add(*, request: Request, obj: AddUserParam) -> None:
"""
添加新用户
:param request: FastAPI 请求对象
:param obj: 用户添加参数
:return:
"""
async with async_db_session.begin() as db:
superuser_verify(request)
username = await user_dao.get_by_username(db, obj.username)
if username:
raise errors.ForbiddenError(msg='用户已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
if not obj.password:
raise errors.ForbiddenError(msg='密码为空')
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
dept = await dept_dao.get(db, obj.dept_id)
if not dept:
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
role = await role_dao.get(db, role_id)
if not role:
raise errors.NotFoundError(msg='角色不存在')
await user_dao.add(db, obj)
@staticmethod
async def pwd_reset(*, request: Request, obj: ResetPasswordParam) -> int:
"""
重置用户密码
:param request: FastAPI 请求对象
:param obj: 密码重置参数
:return:
"""
async with async_db_session.begin() as db:
user = await user_dao.get(db, request.user.id)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.ForbiddenError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.ForbiddenError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, request.user.id, new_pwd)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{request.user.id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
@staticmethod
async def get_userinfo(*, username: str) -> User:
""" """
获取用户信息 获取用户信息
:param db: 数据库会话
:param pk: 用户 ID
:param username: 用户名 :param username: 用户名
:return: :return:
""" """
async with async_db_session() as db: user = await user_dao.get_join(db, user_id=pk, username=username)
user = await user_dao.get_with_relation(db, username=username) if not user:
if not user: raise errors.NotFoundError(msg='用户不存在')
raise errors.NotFoundError(msg='用户不存在') return user
return user
@staticmethod @staticmethod
async def update(*, request: Request, username: str, obj: UpdateUserParam) -> int: async def get_roles(*, db: AsyncSession, pk: int) -> Sequence[Role]:
""" """
更新用户信息 获取用户所有角色
:param request: FastAPI 请求对象 :param db: 数据库会话
:param username: 用户 :param pk: 用户 ID
:param obj: 用户更新参数
:return: :return:
""" """
async with async_db_session.begin() as db: user = await user_dao.get_join(db, user_id=pk)
if request.user.username != username: if not user:
raise errors.ForbiddenError(msg='你只能修改自己的信息') raise errors.NotFoundError(msg='用户不存在')
user = await user_dao.get_with_relation(db, username=username) return user.roles
if not user:
raise errors.NotFoundError(msg='用户不存在')
if user.username != obj.username:
_username = await user_dao.get_by_username(db, obj.username)
if _username:
raise errors.ForbiddenError(msg='用户名已注册')
if user.nickname != obj.nickname:
nickname = await user_dao.get_by_nickname(db, obj.nickname)
if nickname:
raise errors.ForbiddenError(msg='昵称已注册')
if user.email != obj.email:
email = await user_dao.check_email(db, obj.email)
if email:
raise errors.ForbiddenError(msg='邮箱已注册')
for role_id in obj.roles:
role = await role_dao.get(db, role_id)
if not role:
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def get_select(*, dept: int, username: str, phone: str, status: int) -> Select: async def get_list(*, db: AsyncSession, dept: int, username: str, phone: str, status: int) -> dict[str, Any]:
""" """
获取用户列表查询条件 获取用户列表
:param db: 数据库会话
:param dept: 部门 ID :param dept: 部门 ID
:param username: 用户名 :param username: 用户名
:param phone: 手机号 :param phone: 手机号
:param status: 状态 :param status: 状态
:return: :return:
""" """
return await user_dao.get_list(dept=dept, username=username, phone=phone, status=status) user_select = await user_dao.get_select(dept=dept, username=username, phone=phone, status=status)
data = await paging_data(db, user_select)
if data['items']:
serialized_items = select_join_serialize(data['items'], relationships=['User-m2o-Dept', 'User-m2m-Role'])
# 确保返回的是列表,即使只有一个元素
data['items'] = [serialized_items] if not isinstance(serialized_items, list) else serialized_items
return data
@staticmethod @staticmethod
async def update_permission(*, request: Request, pk: int) -> int: async def create(*, db: AsyncSession, obj: AddUserParam) -> None:
"""
创建用户
:param db: 数据库会话
:param obj: 用户添加参数
:return:
"""
if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
if not obj.password:
raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
await user_dao.add(db, obj)
@staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param db: 数据库会话
:param pk: 用户 ID
:param obj: 用户更新参数
:return:
"""
user = await user_dao.get_join(db, user_id=pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册')
if obj.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:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod
async def update_permission(*, db: AsyncSession, request: Request, pk: int, type: UserPermissionType) -> int: # noqa: C901
""" """
更新用户权限 更新用户权限
:param db: 数据库会话
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:param pk: 用户 ID :param pk: 用户 ID
:param type: 权限类型
:return: :return:
""" """
async with async_db_session.begin() as db: match type:
superuser_verify(request) case UserPermissionType.superuser:
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='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='非法操作') raise errors.ForbiddenError(msg='禁止修改自身权限')
super_status = await user_dao.get_super(db, pk) count = await user_dao.set_super(db, pk, is_super=not user.is_superuser)
count = await user_dao.set_super(db, pk, not super_status) case UserPermissionType.staff:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') user = await user_dao.get(db, pk)
return count if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_staff(db, pk, is_staff=not user.is_staff)
case UserPermissionType.status:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_status(db, pk, 0 if user.status == 1 else 1)
case UserPermissionType.multi_login:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
token = get_token(request)
token_payload = jwt_decode(token)
if pk == user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(
key_prefix,
exclude=f'{key_prefix}:{token_payload.session_uuid}',
)
else:
# 系统管理员修改他人时,他人 token 全部失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(key_prefix)
case _:
raise errors.RequestError(msg='权限类型不存在')
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def update_staff(*, request: Request, pk: int) -> int: async def reset_password(*, db: AsyncSession, pk: int, password: str) -> int:
""" """
更新用户职员状态 重置用户密码
:param request: FastAPI 请求对象 :param db: 数据库会话
:param pk: 用户 ID :param pk: 用户 ID
:param password: 新密码
:return: :return:
""" """
async with async_db_session.begin() as db: user = await user_dao.get(db, pk)
superuser_verify(request) if not user:
user = await user_dao.get(db, pk) raise errors.NotFoundError(msg='用户不存在')
if not user:
raise errors.NotFoundError(msg='用户不存在') await validate_new_password(db, user.id, password)
if pk == request.user.id: count = await user_dao.reset_password(db, user.id, password)
raise errors.ForbiddenError(msg='非法操作')
staff_status = await user_dao.get_staff(db, pk) history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
count = await user_dao.set_staff(db, pk, not staff_status) await password_security_service.save_password_history(db, history_obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await user_dao.update_password_changed_time(db, user.id)
return count
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}',
]
for prefix in key_prefix:
await redis_client.delete(prefix)
return count
@staticmethod @staticmethod
async def update_status(*, request: Request, pk: int) -> int: async def update_nickname(*, db: AsyncSession, user_id: int, nickname: str) -> int:
""" """
更新用户状态 更新当前用户昵称
:param request: FastAPI 请求对象 :param db: 数据库会话
:param pk: 用户 ID :param user_id: 用户 ID
:param nickname: 用户昵称
:return: :return:
""" """
async with async_db_session.begin() as db: count = await user_dao.update_nickname(db, user_id, nickname)
superuser_verify(request) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
user = await user_dao.get(db, pk) return count
if not user:
raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id:
raise errors.ForbiddenError(msg='非法操作')
status = await user_dao.get_status(db, pk)
count = await user_dao.set_status(db, pk, 0 if status == 1 else 1)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@staticmethod @staticmethod
async def update_multi_login(*, request: Request, pk: int) -> int: async def update_avatar(*, db: AsyncSession, user_id: int, avatar: str) -> int:
""" """
更新用户多端登录状态 更新当前用户头像
:param request: FastAPI 请求对象 :param db: 数据库会话
:param pk: 用户 ID :param user_id: 用户 ID
:param avatar: 头像地址
:return: :return:
""" """
async with async_db_session.begin() as db: count = await user_dao.update_avatar(db, user_id, avatar)
superuser_verify(request) await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
user = await user_dao.get(db, pk) return count
if not user:
raise errors.NotFoundError(msg='用户不存在')
multi_login = await user_dao.get_multi_login(db, pk) if pk != user.id else request.user.is_multi_login
new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, new_multi_login)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
token = get_token(request)
token_payload = jwt_decode(token)
if pk == user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}')
else:
# 系统管理员修改他人时,他人 token 全部失效
if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix(key_prefix)
return count
@staticmethod @staticmethod
async def delete(*, username: str) -> int: async def update_email(*, db: AsyncSession, user_id: int, captcha: str, email: str) -> int:
"""
更新当前用户邮箱
:param db: 数据库会话
:param user_id: 用户 ID
:param captcha: 邮箱验证码
:param email: 邮箱
:return:
"""
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code:
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count
@staticmethod
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
"""
更新当前用户密码
:param db: 数据库会话
:param user_id: 用户 ID
:param obj: 密码重置参数
:return:
"""
user = await user_dao.get(db, user_id)
if user.password and not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='两次密码输入不一致')
await validate_new_password(db, user_id, obj.new_password)
count = await user_dao.reset_password(db, user_id, obj.new_password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)
return count
@staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int:
""" """
删除用户 删除用户
:param username: 用户名 :param db: 数据库会话
:param pk: 用户 ID
:return: :return:
""" """
async with async_db_session.begin() as db: user = await user_dao.get(db, pk)
user = await user_dao.get_by_username(db, username) if not user:
if not user: raise errors.NotFoundError(msg='用户不存在')
raise errors.NotFoundError(msg='用户不存在') count = await user_dao.delete(db, user.id)
count = await user_dao.delete(db, user.id) 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}', ]
] for key in key_prefix:
for key in key_prefix: await redis_client.delete_prefix(key)
await redis_client.delete_prefix(key) return count
return count
user_service: UserService = UserService() user_service: UserService = UserService()
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from starlette.testclient import TestClient from starlette.testclient import TestClient
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Generator
# -*- coding: utf-8 -*-
from typing import Generator
import pytest import pytest
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import AsyncGenerator
# -*- coding: utf-8 -*-
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio.session import AsyncSession
View File
+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.re_verify 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
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1 from backend.app.admin.api.router import v1 as admin_v1
+4 -5
View File
@@ -3,20 +3,19 @@
当前任务使用 Celery 当前任务使用 Celery
实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225) 实现,实施方案请查看 [#225](https://github.com/fastapi-practices/fastapi_best_architecture/discussions/225)
## 添加任务 ## 定时任务
> [!IMPORTANT] `backend/app/task/tasks/beat.py` 文件内编写相关定时任务
> 由于 Celery 任务扫描规则,使其对任务的目录结构要求及其严格,务必在 celery_task 目录下添加任务
### 简单任务 ### 简单任务
可以直接`tasks.py` 文件内编写相关任务代码 `backend/app/task/tasks/tasks.py` 文件内编写相关任务代码
### 层级任务 ### 层级任务
如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是 如果你想对任务进行目录层级划分,使任务结构更加清晰,你可以新建任意目录,但必须注意的是
1. 新建目录后,务必更新任务配置 `CELERY_TASKS_PACKAGES`,将新建目录添加到此列表 1. `backend/app/task/tasks` 目录下新建 python 包目录
2. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码 2. 在新建目录下,务必添加 `tasks.py` 文件,并在此文件中编写相关任务代码
## 消息代理 ## 消息代理
+4 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys import sys
from pathlib import Path from backend.core.path_conf import BASE_PATH
from .actions import * # noqa: F403
# 导入项目根目录 # 导入项目根目录
sys.path.append(str(Path(__file__).resolve().parent.parent.parent.parent)) sys.path.append(str(BASE_PATH.parent))
+11
View File
@@ -0,0 +1,11 @@
from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app
from backend.common.socketio.server import sio
@sio.event
async def task_worker_status(sid, data) -> None: # noqa: ANN001
"""任务 Worker 状态事件"""
worker = await run_in_threadpool(celery_app.control.ping)
await sio.emit('task_worker_status', worker, sid)
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+7 -5
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.task.api.v1.task import router as task_router from backend.app.task.api.v1.control import router as task_control_router
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.core.conf import settings from backend.core.conf import settings
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH, tags=['任务'])
v1.include_router(task_router, prefix='/tasks', tags=['任务']) v1.include_router(task_control_router, prefix='/tasks')
v1.include_router(task_result_router, prefix='/task-results')
v1.include_router(task_scheduler_router, prefix='/schedulers')

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