Compare commits

...
20 Commits
Author SHA1 Message Date
Wu Clan 123a44aed0 Ignore AI Buddy downloaded static files (#1262) 2026-09-20 11:01:09 +08:00
Wu Clan 9ec613eddc Rename task scheduler enabled field to status (#1261) 2026-09-20 10:48:48 +08:00
Wu Clan 8d9a79dee4 Reuse Redis key strings in local variables (#1259) 2026-09-16 17:55:50 +08:00
Wu Clan 935c49487e Fix scheduler background task garbage collection (#1257) 2026-09-16 16:37:20 +08:00
Wu Clan 54a438c762 Fix snowflake initialization blocking the event loop (#1256) 2026-09-16 16:36:37 +08:00
Wu Clan 7ab3bb81dd Fix distributed reload lock timeout handling (#1255) 2026-09-16 16:36:04 +08:00
Wu Clan 2054c5bdc4 Fix rate limiter Redis bucket cache lock contention (#1254) 2026-09-16 16:35:46 +08:00
Wu Clan 110a9c2e0c Refactor token session index and online status (#1253)
* Optimize Redis connection pool and batch operations

* Refactor token session index and online status
2026-09-16 16:31:24 +08:00
Wu Clan c5c043052c Optimize Redis connection pool and batch operations (#1252) 2026-09-16 16:28:35 +08:00
Wu Clan c5f6362e67 Update test token fixture to logout after use (#1251) 2026-09-16 10:47:06 +08:00
Wu Clan cd0d1f3ab8 Update code generator import order (#1250) 2026-09-16 10:47:01 +08:00
Wu Clan d47534ad8b Update cursor pagination page size params (#1249) 2026-09-16 10:46:57 +08:00
Wu Clan 315f8a55cc Update admin and task naming conventions (#1246) 2026-09-07 10:42:15 +08:00
Wu Clan 5a658c44f8 Update oauth2 plugin README and service docstring (#1245) 2026-09-07 10:36:42 +08:00
Wu Clan 5ae7afaf29 Update email plugin README configuration (#1244) 2026-09-07 10:36:25 +08:00
Wu Clan ae6d165e98 Update code generator plugin naming to CodeGen prefix (#1243) 2026-09-07 10:35:39 +08:00
Wu Clan e5aa1ccea7 Update code generator plugin menu SQL paths (#1242) 2026-09-06 23:01:29 +08:00
Wu Clan ee553f8b67 Update frontend plugin installer suffix handling (#1241) 2026-09-06 22:30:37 +08:00
jerryliu44 a38bd0b4a6 Fix correct multi-login permission check (#1240) 2026-08-28 10:19:29 +08:00
Wu Clan b58cca4788 Add claude.uy sponsor to the README and CLI (#1238) 2026-08-21 18:17:57 +08:00
65 changed files with 1031 additions and 583 deletions
+14
View File
@@ -44,6 +44,20 @@ springBoot...). ), but a self-righteous directory structure that you can give it
For more details, please check For more details, please check
the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## Sponsors
<div align="center">
<table>
<tr>
<td align="center">
<a href="https://claude.uy/home">
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
</a>
</td>
</tr>
</table>
</div>
## Contributors ## Contributors
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors"> <a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
+14
View File
@@ -41,6 +41,20 @@ mvc 架构作为常规设计模式,在 python web 中很常见,但是三层
更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/) 更多详情请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
## 赞助商
<div align="center">
<table>
<tr>
<td align="center">
<a href="https://claude.uy/home">
<img src="https://purple-sun-4f5a.wuyao1243.workers.dev/" alt="Claude.uy" width="400">
</a>
</td>
</tr>
</table>
</div>
## 贡献者 ## 贡献者
<a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors"> <a href="https://github.com/fastapi-practices/fastapi_best_architecture/graphs/contributors">
+1
View File
@@ -2,5 +2,6 @@ __pycache__/
.env .env
alembic/versions/ alembic/versions/
static/media/ static/media/
static/ai_buddy/
*.log *.log
celerybeat-schedule.* celerybeat-schedule.*
+82 -56
View File
@@ -1,82 +1,108 @@
import json import json
from typing import Annotated from typing import TYPE_CHECKING, Annotated, Any
from fastapi import APIRouter, Path, Query 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.exception import errors
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 DependsSuperUser, jwt_decode, revoke_token from backend.common.security.jwt import DependsSuperUser, jwt_decode
from backend.common.security.token import revoke_token
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
if TYPE_CHECKING:
from backend.common.dataclasses import TokenPayload
router = APIRouter() router = APIRouter()
@router.get('', summary='获取在线用户', dependencies=[DependsSuperUser]) @router.get('', summary='获取在线用户', dependencies=[DependsSuperUser])
async def get_sessions( async def get_sessions( # ruff:ignore[complex-structure]
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.get_by_prefix(settings.TOKEN_REDIS_PREFIX) users_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users'
online_clients = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX) user_ids = list(await redis_client.smembers(users_key))
data: list[GetTokenDetail] = [] if not user_ids:
if not token_keys: return response_base.success(data=[])
return response_base.success(data=data)
def append_token_detail() -> None: session_sets = await redis_client.smembers_many([
data.append( f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}' for user_id in user_ids
token_detail.model_copy( ])
update={ session_refs: list[tuple[str, str]] = []
'username': extra_info.get('username', '未知'), for user_id, members in zip(user_ids, session_sets, strict=True):
'nickname': extra_info.get('nickname', '未知'), session_refs.extend((user_id, session_uuid) for session_uuid in members)
'ip': extra_info.get('ip', '未知'), if not session_refs:
'os': extra_info.get('os', '未知'), await redis_client.srem(users_key, *user_ids)
'browser': extra_info.get('browser', '未知'), return response_base.success(data=[])
'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'),
},
),
)
token_values = await redis_client.mget(*token_keys) tokens = await redis_client.mget_batched([
token_details: list[GetTokenDetail] = [] f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}' for user_id, session_uuid in session_refs
extra_info_keys: list[str] = [] ])
for token in token_values: token_payloads: list[TokenPayload] = []
live_user_ids: set[str] = set()
for (user_id, _session_uuid), token in zip(session_refs, tokens, strict=True):
if not token: if not token:
continue continue
token_payload = jwt_decode(token) try:
user_id = token_payload.user_id token_payloads.append(jwt_decode(token))
session_uuid = token_payload.session_uuid except errors.TokenError:
token_detail = GetTokenDetail( continue
id=user_id, live_user_ids.add(user_id)
session_uuid=session_uuid, stale_user_ids = [user_id for user_id in user_ids if user_id not in live_user_ids]
username='未知', if stale_user_ids:
nickname='未知', await redis_client.srem(users_key, *stale_user_ids)
ip='未知', if not token_payloads:
os='未知', return response_base.success(data=[])
browser='未知',
device='未知', extra_infos = await redis_client.mget_batched([
status=StatusType.enable if session_uuid in online_clients else StatusType.disable, f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{item.user_id}:{item.session_uuid}' for item in token_payloads
last_login_time='未知', ])
sid_sets = await redis_client.smembers_many([
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{item.session_uuid}' for item in token_payloads
])
sid_list = [sid for members in sid_sets for sid in members]
sid_values = await redis_client.mget_batched([
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}' for sid in sid_list
])
sid_session_map = dict(zip(sid_list, sid_values, strict=True))
online_sessions = {
item.session_uuid
for item, members in zip(token_payloads, sid_sets, strict=True)
if any(sid_session_map.get(sid) == item.session_uuid for sid in members)
}
data: list[GetTokenDetail] = []
for token_payload, extra_info in zip(token_payloads, extra_infos, strict=True):
info: dict[str, Any] = {}
if extra_info:
try:
parsed = json.loads(extra_info)
except (json.JSONDecodeError, TypeError):
parsed = None
if isinstance(parsed, dict):
info = parsed
if info.get('swagger') is not None:
continue
if username is not None and username != info.get('username'):
continue
data.append(
GetTokenDetail(
id=token_payload.user_id,
session_uuid=token_payload.session_uuid,
username=info.get('username', '未知'),
nickname=info.get('nickname', '未知'),
ip=info.get('ip', '未知'),
os=info.get('os', '未知'),
browser=info.get('browser', '未知'),
device=info.get('device', '未知'),
status=StatusType.enable if token_payload.session_uuid in online_sessions else StatusType.disable,
last_login_time=info.get('last_login_time', '未知'),
expire_time=token_payload.expire_time, expire_time=token_payload.expire_time,
) )
token_details.append(token_detail) )
extra_info_keys.append(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}') data.sort(key=lambda item: (item.id, item.session_uuid))
extra_infos = await redis_client.mget(*extra_info_keys) if extra_info_keys else []
for token_detail, extra_info in zip(token_details, extra_infos, strict=True):
if extra_info:
extra_info = json.loads(extra_info)
# 排除 swagger 登录生成的 token
if extra_info.get('swagger') is None:
if username is not None:
if username == extra_info.get('username'):
append_token_detail()
else:
append_token_detail()
else:
data.append(token_detail)
return response_base.success(data=data) return response_base.success(data=data)
+2 -2
View File
@@ -7,7 +7,7 @@ from backend.app.admin.model import OperaLog
from backend.app.admin.schema.opera_log import CreateOperaLogParam from backend.app.admin.schema.opera_log import CreateOperaLogParam
class CRUDOperaLogDao(CRUDPlus[OperaLog]): class CRUDOperaLog(CRUDPlus[OperaLog]):
"""操作日志数据库操作类""" """操作日志数据库操作类"""
async def get_select(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:
@@ -71,4 +71,4 @@ class CRUDOperaLogDao(CRUDPlus[OperaLog]):
await db.execute(sa_delete(OperaLog)) await db.execute(sa_delete(OperaLog))
opera_log_dao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog) opera_log_dao: CRUDOperaLog = CRUDOperaLog(OperaLog)
+4 -4
View File
@@ -7,18 +7,18 @@ from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class DataScopeBase(SchemaBase): class DataScopeSchemaBase(SchemaBase):
"""数据范围基础模型""" """数据范围基础模型"""
name: str = Field(description='名称') name: str = Field(description='名称')
status: StatusType = Field(description='状态') status: StatusType = Field(description='状态')
class CreateDataScopeParam(DataScopeBase): class CreateDataScopeParam(DataScopeSchemaBase):
"""创建数据范围参数""" """创建数据范围参数"""
class UpdateDataScopeParam(DataScopeBase): class UpdateDataScopeParam(DataScopeSchemaBase):
"""更新数据范围参数""" """更新数据范围参数"""
@@ -41,7 +41,7 @@ class DeleteDataScopeParam(SchemaBase):
pks: list[int] = Field(description='数据范围 ID 列表') pks: list[int] = Field(description='数据范围 ID 列表')
class GetDataScopeDetail(DataScopeBase): class GetDataScopeDetail(DataScopeSchemaBase):
"""数据范围详情""" """数据范围详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -3,12 +3,12 @@ from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class UserPasswordHistoryBase(SchemaBase): class UserPasswordHistorySchemaBase(SchemaBase):
"""用户历史密码记录基础模型""" """用户历史密码记录基础模型"""
user_id: int = Field(description='用户 ID') user_id: int = Field(description='用户 ID')
password: str = Field(description='历史密码') password: str = Field(description='历史密码')
class CreateUserPasswordHistoryParam(UserPasswordHistoryBase): class CreateUserPasswordHistoryParam(UserPasswordHistorySchemaBase):
"""创建用户历史密码记录""" """创建用户历史密码记录"""
+14 -22
View File
@@ -17,12 +17,14 @@ from backend.common.exception import errors
from backend.common.i18n import t from backend.common.i18n import t
from backend.common.log import log from backend.common.log import log
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 jwt_decode
from backend.common.security.token import (
create_access_token, create_access_token,
create_new_token, create_new_token,
create_refresh_token, create_refresh_token,
get_token, get_token,
jwt_decode, get_user_sessions,
revoke_token,
) )
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
@@ -35,7 +37,7 @@ class AuthService:
"""认证服务类""" """认证服务类"""
@staticmethod @staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]: async def user_verify(*, db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
""" """
验证用户名和密码 验证用户名和密码
@@ -47,9 +49,7 @@ class AuthService:
user = await user_dao.get_by_username(db, username) user = await user_dao.get_by_username(db, username)
if not user: if not user:
raise errors.NotFoundError(msg='用户名或密码有误') raise errors.NotFoundError(msg='用户名或密码有误')
await password_security_service.check_status(user.id, user.status) await password_security_service.check_status(user.id, user.status)
if user.password is None or not password_verify(password, user.password): if user.password is None or not password_verify(password, user.password):
await password_security_service.handle_login_failure(db, user.id) await password_security_service.handle_login_failure(db, user.id)
raise errors.AuthorizationError(msg='用户名或密码有误') raise errors.AuthorizationError(msg='用户名或密码有误')
@@ -57,7 +57,6 @@ class AuthService:
days_remaining = await password_security_service.check_password_expiry_status( days_remaining = await password_security_service.check_password_expiry_status(
db, user.last_password_changed_time db, user.last_password_changed_time
) )
await password_security_service.handle_login_success(user.id) await password_security_service.handle_login_success(user.id)
return user, days_remaining return user, days_remaining
@@ -70,12 +69,11 @@ class AuthService:
:param obj: 登录凭证 :param obj: 登录凭证
:return: :return:
""" """
user, _ = await self.user_verify(db, obj.username, obj.password) user, _ = await self.user_verify(db=db, username=obj.username, password=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( access_token_data = await create_access_token(
user.id, user.id,
multi_login=user.is_multi_login, multi_login=user.is_multi_login,
# extra info
swagger=True, swagger=True,
) )
return access_token_data.access_token, user return access_token_data.access_token, user
@@ -103,14 +101,15 @@ class AuthService:
if settings.LOGIN_CAPTCHA_ENABLED: if settings.LOGIN_CAPTCHA_ENABLED:
if not obj.uuid or not obj.captcha: if not obj.uuid or not obj.captcha:
raise errors.RequestError(msg=t('error.captcha.invalid')) raise errors.RequestError(msg=t('error.captcha.invalid'))
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}') captcha_key = f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}'
captcha_code = await redis_client.get(captcha_key)
if not captcha_code: if not captcha_code:
raise errors.RequestError(msg=t('error.captcha.expired')) 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.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.uuid}') await redis_client.delete(captcha_key)
user, days_remaining = await self.user_verify(db, obj.username, obj.password) user, days_remaining = await self.user_verify(db=db, username=obj.username, password=obj.password)
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await db.refresh(user)
access_token_data = await create_access_token( access_token_data = await create_access_token(
@@ -218,11 +217,9 @@ class AuthService:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if not user.status: if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
token_keys = await redis_client.get_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}') if not user.is_multi_login and await get_user_sessions(user.id) - {token_payload.session_uuid}:
if not user.is_multi_login and [
key for key in token_keys if not key.endswith(f':{token_payload.session_uuid}')
]:
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码') raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token( new_token = await create_new_token(
refresh_token, refresh_token,
token_payload.session_uuid, token_payload.session_uuid,
@@ -244,6 +241,7 @@ class AuthService:
expires=timezone.to_utc(new_token.new_refresh_token_expire_time), expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
httponly=True, httponly=True,
) )
data = GetNewToken( data = GetNewToken(
access_token=new_token.new_access_token, access_token=new_token.new_access_token,
access_token_expire_time=new_token.new_access_token_expire_time, access_token_expire_time=new_token.new_access_token_expire_time,
@@ -263,18 +261,12 @@ class AuthService:
try: try:
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
user_id = token_payload.user_id
session_uuid = token_payload.session_uuid
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}') await revoke_token(token_payload.user_id, token_payload.session_uuid)
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
if refresh_token:
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
auth_service: AuthService = AuthService() auth_service: AuthService = AuthService()
@@ -28,16 +28,15 @@ class UserPasswordHistoryService:
if not user_status: if not user_status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}') lock_key = f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}'
locked_until_str = await redis_client.get(lock_key)
if locked_until_str: if locked_until_str:
locked_until = timezone.from_str(locked_until_str) locked_until = timezone.from_str(locked_until_str)
now = timezone.now() now = timezone.now()
if locked_until > now: if locked_until > now:
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60) remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试') raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
await redis_client.delete(lock_key)
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}') await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod @staticmethod
@@ -54,14 +53,11 @@ class UserPasswordHistoryService:
if settings.USER_LOCK_THRESHOLD == 0: if settings.USER_LOCK_THRESHOLD == 0:
return return
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}') failure_key = f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}'
failure_count = await redis_client.get(failure_key)
failure_count = int(failure_count) if failure_count else 0 failure_count = int(failure_count) if failure_count else 0
failure_count += 1 failure_count += 1
await redis_client.set( await redis_client.set(failure_key, str(failure_count), ex=settings.USER_LOCK_SECONDS)
f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}',
str(failure_count),
ex=settings.USER_LOCK_SECONDS,
)
if failure_count >= settings.USER_LOCK_THRESHOLD: if failure_count >= settings.USER_LOCK_THRESHOLD:
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS) locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
+19 -25
View File
@@ -21,7 +21,8 @@ from backend.common.enums import UserPermissionType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.common.response.response_code import CustomErrorCode from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode from backend.common.security.jwt import jwt_decode
from backend.common.security.token import get_token, revoke_user_tokens
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize from backend.utils.serializers import select_join_serialize
@@ -169,24 +170,19 @@ class UserService:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login multi_login = user.is_multi_login if pk != request.user.id else request.user.is_multi_login
new_multi_login = not multi_login new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login) count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
if pk == user.id: if pk == request.user.id:
# 系统管理员修改自身时,除当前 token 外,其他 token 失效 # 系统管理员修改自身时,除当前 token 外,其他 token 失效
if not new_multi_login: if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}' await revoke_user_tokens(user.id, exclude_session_uuid=token_payload.session_uuid)
await redis_client.delete_by_prefix(
key_prefix,
exclude_keys=f'{key_prefix}:{token_payload.session_uuid}',
)
else: else:
# 系统管理员修改他人时,他人 token 全部失效 # 系统管理员修改他人时,他人 token 全部失效
if not new_multi_login: if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}' await revoke_user_tokens(user.id)
await redis_client.delete_by_prefix(key_prefix)
case _: case _:
raise errors.RequestError(msg='权限类型不存在') raise errors.RequestError(msg='权限类型不存在')
@@ -209,13 +205,12 @@ class UserService:
await validate_new_password(db, user.id, password) await validate_new_password(db, user.id, password)
count = await user_dao.reset_password(db, user.id, password) count = await user_dao.reset_password(db, user.id, password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password) history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj) await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id) await user_dao.update_password_changed_time(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}') await revoke_user_tokens(user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
@staticmethod @staticmethod
@@ -257,7 +252,8 @@ class UserService:
:param email: 邮箱 :param email: 邮箱
:return: :return:
""" """
captcha_code = await redis_client.get(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}') captcha_key = f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}'
captcha_code = await redis_client.get(captcha_key)
if not captcha_code: if not captcha_code:
raise errors.RequestError(msg='验证码已失效,请重新获取') raise errors.RequestError(msg='验证码已失效,请重新获取')
if captcha != captcha_code: if captcha != captcha_code:
@@ -265,7 +261,7 @@ class UserService:
email_user = await user_dao.check_email(db, email) email_user = await user_dao.check_email(db, email)
if email_user and email_user.id != user_id: if email_user and email_user.id != user_id:
raise errors.ConflictError(msg='邮箱已被绑定') raise errors.ConflictError(msg='邮箱已被绑定')
await redis_client.delete(f'{settings.EMAIL_CAPTCHA_REDIS_PREFIX}:{ctx.ip}') await redis_client.delete(captcha_key)
count = await user_dao.update_email(db, user_id, email) count = await user_dao.update_email(db, user_id, email)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count return count
@@ -281,22 +277,19 @@ class UserService:
:return: :return:
""" """
user = await user_dao.get(db, user_id) user = await user_dao.get(db, user_id)
if user.password and not password_verify(obj.old_password, user.password): if user.password and not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误') raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password: if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='两次密码输入不一致') raise errors.RequestError(msg='两次密码输入不一致')
await validate_new_password(db, user_id, obj.new_password) await validate_new_password(db, user_id, obj.new_password)
count = await user_dao.reset_password(db, user_id, obj.new_password) count = await user_dao.reset_password(db, user_id, obj.new_password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password) history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj) await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id) await user_dao.update_password_changed_time(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}') await revoke_user_tokens(user_id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}')
return count return count
@staticmethod @staticmethod
@@ -311,10 +304,11 @@ class UserService:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
count = await user_dao.delete(db, user.id) count = await user_dao.delete(db, user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}') await revoke_user_tokens(user.id)
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}') await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
await redis_client.delete_by_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count return count
+4 -4
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Path
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.task import celery_app from backend.app.task import celery_app
from backend.app.task.schema.control import TaskRegisteredDetail from backend.app.task.schema.control import GetTaskRegisteredDetail
from backend.common.exception import errors from backend.common.exception import errors
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
@@ -15,7 +15,7 @@ router = APIRouter()
@router.get('/registered', summary='获取已注册的任务', dependencies=[DependsJwtAuth]) @router.get('/registered', summary='获取已注册的任务', dependencies=[DependsJwtAuth])
async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail]]: async def get_task_registered() -> ResponseSchemaModel[list[GetTaskRegisteredDetail]]:
inspector = celery_app.control.inspect(timeout=0.5) inspector = celery_app.control.inspect(timeout=0.5)
registered = await run_in_threadpool(inspector.registered) registered = await run_in_threadpool(inspector.registered)
if not registered: if not registered:
@@ -26,9 +26,9 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail
for task in tasks: for task in tasks:
task_ins = celery_app_tasks.get(task) task_ins = celery_app_tasks.get(task)
if task_ins: if task_ins:
task_registered.append(TaskRegisteredDetail(name=task_ins.__doc__ or task, task=task)) task_registered.append(GetTaskRegisteredDetail(name=task_ins.__doc__ or task, task=task))
else: else:
task_registered.append(TaskRegisteredDetail(name=task, task=task)) task_registered.append(GetTaskRegisteredDetail(name=task, task=task))
return response_base.success(data=task_registered) return response_base.success(data=task_registered)
+2 -2
View File
@@ -85,7 +85,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
TaskScheduler.no_changes = False TaskScheduler.no_changes = False
return 1 return 1
async def set_status(self, db: AsyncSession, pk: int, *, status: bool) -> int: async def set_status(self, db: AsyncSession, pk: int, *, status: int) -> int:
""" """
设置任务调度状态 设置任务调度状态
@@ -95,7 +95,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
:return: :return:
""" """
task_scheduler = await self.get(db, pk) task_scheduler = await self.get(db, pk)
task_scheduler.enabled = status task_scheduler.status = status
TaskScheduler.no_changes = False TaskScheduler.no_changes = False
return 1 return 1
+8 -2
View File
@@ -1,12 +1,14 @@
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from typing import ClassVar
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.enums import StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.model import Base, TimeZone, UniversalText, id_key from backend.common.model import Base, TimeZone, UniversalText, id_key
from backend.core.conf import settings from backend.core.conf import settings
@@ -39,12 +41,14 @@ class TaskScheduler(Base):
interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型') interval_period: Mapped[str | None] = mapped_column(sa.String(256), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='Crontab 表达式') crontab: Mapped[str | None] = mapped_column(sa.String(64), default='* * * * *', comment='Crontab 表达式')
one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次') one_off: Mapped[bool] = mapped_column(default=False, comment='是否仅运行一次')
enabled: Mapped[bool] = mapped_column(default=True, comment='是否启用任务') status: Mapped[int] = mapped_column(default=StatusType.enable.value, comment='状态(0停用 1正常)')
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数') total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间') last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注') remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注')
no_changes: bool = False no_changes: bool = False
# 持有后台任务引用,避免 create_task 返回的任务在执行前被回收
_update_tasks: ClassVar[set[asyncio.Task]] = set()
@staticmethod @staticmethod
def before_insert_or_update(mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument] def before_insert_or_update(mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
@@ -63,7 +67,9 @@ class TaskScheduler(Base):
@classmethod @classmethod
def update_changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument] def update_changed(cls, mapper, connection, target) -> None: # ruff:ignore[missing-type-function-argument]
asyncio.create_task(cls.update_changed_async()) task = asyncio.create_task(cls.update_changed_async())
cls._update_tasks.add(task)
task.add_done_callback(cls._update_tasks.discard)
# 事件监听器 # 事件监听器
+7 -3
View File
@@ -1,6 +1,10 @@
from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class TaskRegisteredDetail(SchemaBase): class GetTaskRegisteredDetail(SchemaBase):
name: str """已注册任务详情"""
task: str
name: str = Field(description='任务名称')
task: str = Field(description='任务函数')
+17 -16
View File
@@ -4,45 +4,46 @@ from pydantic import ConfigDict, Field
from pydantic.types import JsonValue from pydantic.types import JsonValue
from backend.app.task.enums import PeriodType, TaskSchedulerType from backend.app.task.enums import PeriodType, TaskSchedulerType
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
class TaskSchedulerSchemeBase(SchemaBase): class TaskSchedulerSchemaBase(SchemaBase):
"""任务调度参数""" """任务调度参数"""
name: str = Field(description='任务名称') name: str = Field(description='任务名称')
task: str = Field(description='要运行的 Celery 任务') task: str = Field(description='要运行的 Celery 任务')
args: JsonValue | None = Field(default=None, description='任务可接收的位置参数') args: JsonValue | None = Field(None, description='任务可接收的位置参数')
kwargs: JsonValue | None = Field(default=None, description='任务可接收的关键字参数') kwargs: JsonValue | None = Field(None, description='任务可接收的关键字参数')
queue: str | None = Field(default=None, description='CELERY_TASK_QUEUES 中定义的队列') queue: str | None = Field(None, description='CELERY_TASK_QUEUES 中定义的队列')
exchange: str | None = Field(default=None, description='低级别 AMQP 路由的交换机') exchange: str | None = Field(None, description='低级别 AMQP 路由的交换机')
routing_key: str | None = Field(default=None, description='低级别 AMQP 路由的路由密钥') routing_key: str | None = Field(None, description='低级别 AMQP 路由的路由密钥')
start_time: datetime | None = Field(default=None, description='任务开始触发的时间') start_time: datetime | None = Field(None, description='任务开始触发的时间')
expire_time: datetime | None = Field(default=None, description='任务不再触发的截止时间') expire_time: datetime | None = Field(None, description='任务不再触发的截止时间')
expire_seconds: int | None = Field(default=None, description='任务不再触发的秒数时间差') expire_seconds: int | None = Field(None, description='任务不再触发的秒数时间差')
type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)') type: TaskSchedulerType = Field(description='任务调度类型(0间隔 1定时)')
interval_every: int | None = Field(default=None, description='任务再次运行前的间隔周期数') interval_every: int | None = Field(None, description='任务再次运行前的间隔周期数')
interval_period: PeriodType | None = Field(default=None, description='任务运行之间的周期类型') interval_period: PeriodType | None = Field(None, description='任务运行之间的周期类型')
crontab: str = Field(default='* * * * *', description='Crontab 表达式') crontab: str = Field(default='* * * * *', description='Crontab 表达式')
one_off: bool = Field(default=False, description='是否仅运行一次') one_off: bool = Field(default=False, description='是否仅运行一次')
remark: str | None = Field(default=None, description='备注') remark: str | None = Field(None, description='备注')
class CreateTaskSchedulerParam(TaskSchedulerSchemeBase): class CreateTaskSchedulerParam(TaskSchedulerSchemaBase):
"""创建任务调度参数""" """创建任务调度参数"""
class UpdateTaskSchedulerParam(TaskSchedulerSchemeBase): class UpdateTaskSchedulerParam(TaskSchedulerSchemaBase):
"""更新任务调度参数""" """更新任务调度参数"""
class GetTaskSchedulerDetail(TaskSchedulerSchemeBase): class GetTaskSchedulerDetail(TaskSchedulerSchemaBase):
"""任务调度详情""" """任务调度详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int = Field(description='任务调度 ID') id: int = Field(description='任务调度 ID')
enabled: bool = Field(description='是否启用任务') status: StatusType = Field(description='状态')
total_run_count: int = Field(description='已运行总次数') total_run_count: int = Field(description='已运行总次数')
last_run_time: datetime | None = Field(None, description='最后运行时间') last_run_time: datetime | None = Field(None, description='最后运行时间')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
@@ -10,6 +10,8 @@ from backend.common.pagination import paging_data
class TaskResultService: class TaskResultService:
"""任务结果服务类"""
@staticmethod @staticmethod
async def get(*, db: AsyncSession, pk: int) -> TaskResult: async def get(*, db: AsyncSession, pk: int) -> TaskResult:
""" """
@@ -12,6 +12,7 @@ from backend.app.task.enums import TaskSchedulerType
from backend.app.task.model import TaskScheduler from backend.app.task.model import TaskScheduler
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, UpdateTaskSchedulerParam
from backend.app.task.utils.tzcrontab import crontab_verify from backend.app.task.utils.tzcrontab import crontab_verify
from backend.common.enums import StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
@@ -110,7 +111,8 @@ class TaskSchedulerService:
task_scheduler = await task_scheduler_dao.get(db, pk) task_scheduler = await task_scheduler_dao.get(db, pk)
if not task_scheduler: if not task_scheduler:
raise errors.NotFoundError(msg='任务调度不存在') raise errors.NotFoundError(msg='任务调度不存在')
count = await task_scheduler_dao.set_status(db, pk, status=not task_scheduler.enabled) next_status = StatusType.disable if task_scheduler.status == StatusType.enable else StatusType.enable
count = await task_scheduler_dao.set_status(db, pk, status=next_status)
return count return count
@staticmethod @staticmethod
+17 -10
View File
@@ -19,6 +19,7 @@ from backend.app.task.enums import PeriodType, TaskSchedulerType
from backend.app.task.model.scheduler import TaskScheduler from backend.app.task.model.scheduler import TaskScheduler
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam from backend.app.task.schema.scheduler import CreateTaskSchedulerParam
from backend.app.task.utils.tzcrontab import TzAwareCrontab, crontab_verify from backend.app.task.utils.tzcrontab import TzAwareCrontab, crontab_verify
from backend.common.enums import StatusType
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -91,22 +92,24 @@ class ModelEntry(ScheduleEntry):
self.last_run_at = timezone.from_datetime(model.last_run_time) self.last_run_at = timezone.from_datetime(model.last_run_time)
self.options['periodic_task_name'] = model.name self.options['periodic_task_name'] = model.name
self.model = model self.model = model
self.enabled = model.status == StatusType.enable
async def _disable(self, model: TaskScheduler) -> None: async def _disable(self, model: TaskScheduler) -> None:
"""禁用任务""" """禁用任务"""
model.no_changes = True model.no_changes = True
self.model.enabled = self.enabled = model.enabled = False self.model.status = model.status = StatusType.disable
self.enabled = False
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
stmt = select(TaskScheduler).where(TaskScheduler.id == model.id, TaskScheduler.deleted == 0) stmt = select(TaskScheduler).where(TaskScheduler.id == model.id, TaskScheduler.deleted == 0)
query = await db.execute(stmt) query = await db.execute(stmt)
task = query.scalars().first() task = query.scalars().first()
if task: if task:
task.no_changes = True task.no_changes = True
task.enabled = False task.status = StatusType.disable
def is_due(self) -> tuple[bool, int | float | datetime]: def is_due(self) -> tuple[bool, int | float | datetime]:
"""任务到期状态""" """任务到期状态"""
if not self.model.enabled: if self.model.status != StatusType.enable:
# 重新启用时延迟 5 秒 # 重新启用时延迟 5 秒
return schedules.schedstate(is_due=False, next=5) return schedules.schedstate(is_due=False, next=5)
@@ -119,11 +122,11 @@ class ModelEntry(ScheduleEntry):
return schedules.schedstate(is_due=False, next=delay) return schedules.schedstate(is_due=False, next=delay)
# 一次性任务 # 一次性任务
if self.model.one_off and self.model.enabled and self.model.total_run_count > 0: if self.model.one_off and self.model.status == StatusType.enable and self.model.total_run_count > 0:
self.model.enabled = False self.model.status = StatusType.disable
self.model.total_run_count = 0 self.model.total_run_count = 0
self.model.no_changes = False self.model.no_changes = False
save_fields = ('enabled',) save_fields = ('status',)
run_await(self.save)(save_fields) run_await(self.save)(save_fields)
return schedules.schedstate(is_due=False, next=1000000000) # 高延迟,避免重新检查 return schedules.schedstate(is_due=False, next=1000000000) # 高延迟,避免重新检查
@@ -237,6 +240,9 @@ class ModelEntry(ScheduleEntry):
**cls._unpack_options(**options or {}), **cls._unpack_options(**options or {}),
**entry, **entry,
) )
if 'enabled' in model_dict:
enabled = model_dict.pop('enabled')
model_dict['status'] = StatusType.enable if enabled else StatusType.disable
return model_dict return model_dict
@classmethod @classmethod
@@ -359,7 +365,7 @@ class DatabaseScheduler(Scheduler):
try: try:
for name, entry_fields in beat_dict.items(): for name, entry_fields in beat_dict.items():
entry = run_await(self.Entry.from_entry)(name, app=self.app, **entry_fields) entry = run_await(self.Entry.from_entry)(name, app=self.app, **entry_fields)
if entry.model.enabled: if entry.model.status == StatusType.enable:
s[name] = entry s[name] = entry
except Exception: except Exception:
logger.error(f'添加任务 {name} 到数据库失败') logger.error(f'添加任务 {name} 到数据库失败')
@@ -371,9 +377,10 @@ class DatabaseScheduler(Scheduler):
def schedule_changed(self) -> bool | None: def schedule_changed(self) -> bool | None:
"""任务调度变更状态""" """任务调度变更状态"""
now = timezone.now() now = timezone.now()
last_update = run_await(redis_client.get)(f'{settings.CELERY_REDIS_PREFIX}:last_update') last_update_key = f'{settings.CELERY_REDIS_PREFIX}:last_update'
last_update = run_await(redis_client.get)(last_update_key)
if not last_update: if not last_update:
run_await(redis_client.set)(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now)) run_await(redis_client.set)(last_update_key, timezone.to_str(now))
return False return False
last, ts = self._last_update, timezone.from_str(last_update) last, ts = self._last_update, timezone.from_str(last_update)
@@ -388,7 +395,7 @@ class DatabaseScheduler(Scheduler):
async with async_db_session() as db: async with async_db_session() as db:
logger.debug('DatabaseScheduler: Fetching database schedule') logger.debug('DatabaseScheduler: Fetching database schedule')
stmt = select(TaskScheduler).where( stmt = select(TaskScheduler).where(
TaskScheduler.enabled.is_(True), TaskScheduler.status == StatusType.enable,
TaskScheduler.deleted == 0, TaskScheduler.deleted == 0,
) )
query = await db.execute(stmt) query = await db.execute(stmt)
+13 -8
View File
@@ -261,8 +261,10 @@ async def init(db: AsyncSession, redis: RedisCli) -> None:
for prefix in [ for prefix in [
settings.JWT_USER_REDIS_PREFIX, settings.JWT_USER_REDIS_PREFIX,
settings.TOKEN_EXTRA_INFO_REDIS_PREFIX, settings.TOKEN_EXTRA_INFO_REDIS_PREFIX,
settings.TOKEN_ONLINE_REDIS_PREFIX,
settings.TOKEN_REDIS_PREFIX, settings.TOKEN_REDIS_PREFIX,
settings.TOKEN_REFRESH_REDIS_PREFIX, settings.TOKEN_REFRESH_REDIS_PREFIX,
settings.TOKEN_SESSION_REDIS_PREFIX,
]: ]:
await redis.delete_by_prefix(prefix) await redis.delete_by_prefix(prefix)
@@ -318,6 +320,9 @@ def run(host: str, port: int, reload: bool, workers: int) -> None: # ruff:ignor
panel_content.append('\n🌐 架构官方文档: ', style='bold magenta') panel_content.append('\n🌐 架构官方文档: ', style='bold magenta')
panel_content.append('https://docs.fba.wu-clan.cc/fastapi_best_architecture_docs/') panel_content.append('https://docs.fba.wu-clan.cc/fastapi_best_architecture_docs/')
panel_content.append('\n\n赞助商:', style='bold yellow')
panel_content.append('Claude.uy', style='link https://claude.uy/home')
console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2))) console.print(Panel(panel_content, title=f'fba (v{__version__})', border_style='purple', padding=(1, 2)))
granian.Granian( granian.Granian(
target='backend.main:app', target='backend.main:app',
@@ -594,15 +599,15 @@ async def import_table(
raise cappa.Exit('代码生成仅在开发环境可用', code=1) raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try: try:
from backend.plugin.code_generator.schema.gen import ImportParam from backend.plugin.code_generator.schema.code_gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service from backend.plugin.code_generator.service.code_gen_service import code_gen_service
except ImportError: except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1) raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try: try:
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name) obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
await gen_service.import_business_and_model(db=db, obj=obj) await code_gen_service.import_business_and_model(db=db, obj=obj)
console.tip('代码生成业务和模型列导入成功') console.tip('代码生成业务和模型列导入成功')
console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~') console.log('\n快试试 [bold cyan]fba codegen[/bold cyan] 生成代码吧~')
except Exception as e: except Exception as e:
@@ -615,15 +620,15 @@ async def generate(*, preview: bool = False) -> None:
raise cappa.Exit('代码生成仅在开发环境可用', code=1) raise cappa.Exit('代码生成仅在开发环境可用', code=1)
try: try:
from backend.plugin.code_generator.service.business_service import gen_business_service from backend.plugin.code_generator.service.business_service import code_gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service from backend.plugin.code_generator.service.code_gen_service import code_gen_service
except ImportError: except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1) raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try: try:
ids = [] ids = []
async with async_db_session() as db: async with async_db_session() as db:
results = await gen_business_service.get_all(db=db) results = await code_gen_business_service.get_all(db=db)
if not results: if not results:
raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]') raise cappa.Exit('[red]暂无可用的代码生成业务!请先通过 import 命令导入![/]')
@@ -648,7 +653,7 @@ async def generate(*, preview: bool = False) -> None:
# 预览 # 预览
async with async_db_session() as db: async with async_db_session() as db:
preview_data = await gen_service.preview(db=db, pk=business) preview_data = await code_gen_service.preview(db=db, pk=business)
console.print('\n[bold yellow]将要生成以下文件:[/]') console.print('\n[bold yellow]将要生成以下文件:[/]')
file_table = Table(show_header=True, header_style='bold cyan') file_table = Table(show_header=True, header_style='bold cyan')
@@ -672,7 +677,7 @@ async def generate(*, preview: bool = False) -> None:
if ok.lower() == 'y': if ok.lower() == 'y':
async with async_db_session.begin() as db: async with async_db_session.begin() as db:
gen_path = await gen_service.generate(db=db, pk=business) gen_path = await code_gen_service.generate(db=db, pk=business)
console.print() console.print()
console.tip('代码已生成完成') console.tip('代码已生成完成')
+10 -3
View File
@@ -38,15 +38,22 @@ class CachePubSubManager:
try: try:
# 使用独立连接 # 使用独立连接
pubsub_client = RedisCli(socket_timeout=None) pubsub_client = RedisCli(max_connections=1)
pubsub = pubsub_client.pubsub() pubsub = pubsub_client.pubsub()
await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL) await pubsub.subscribe(settings.CACHE_PUBSUB_CHANNEL)
# 发布订阅成功 # 发布订阅成功
reconnect_attempts = 0 reconnect_attempts = 0
async for message in pubsub.listen(): # 带超时轮询而不是 listen(),每次进入读取都会触发健康检查 PING,
if message['type'] == 'message': # 避免连接被静默断开后协程永久挂起
while True:
message = await pubsub.get_message(
ignore_subscribe_messages=True,
timeout=settings.CACHE_PUBSUB_POLL_TIMEOUT,
)
if message is None or message['type'] != 'message':
continue
try: try:
data = json.loads(message['data']) data = json.loads(message['data'])
cache_key = data['cache_key'] cache_key = data['cache_key']
+7 -1
View File
@@ -34,6 +34,12 @@ class _CustomPageParams(BaseModel, AbstractParams):
) )
class _CustomCursorParams(CursorParams):
"""自定义游标分页参数"""
size: int = Query(50, ge=0, le=200, description='每页数量')
class _Links(BaseModel): class _Links(BaseModel):
"""分页链接""" """分页链接"""
@@ -98,7 +104,7 @@ class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
class _CustomCursorPage(_CursorPageDetails, AbstractPage[T], Generic[T]): class _CustomCursorPage(_CursorPageDetails, AbstractPage[T], Generic[T]):
"""自定义游标分页类""" """自定义游标分页类"""
__params_type__ = CursorParams __params_type__ = _CustomCursorParams
@classmethod @classmethod
def create( def create(
+5 -137
View File
@@ -1,12 +1,7 @@
import json
import uuid
from datetime import timedelta
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import Depends, Request from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.security.utils import get_authorization_scheme_param
from jose import ExpiredSignatureError, JWTError, jwt from jose import ExpiredSignatureError, JWTError, jwt
from pydantic_core import from_json from pydantic_core import from_json
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +10,7 @@ from starlette.authentication import UnauthenticatedUser
from backend.app.admin.model import User from backend.app.admin.model import User
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.context import ctx from backend.common.context import ctx
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload from backend.common.dataclasses import TokenPayload
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import async_db_session from backend.database.db import async_db_session
@@ -63,132 +58,6 @@ def jwt_decode(token: str) -> TokenPayload:
) )
async def create_access_token(user_id: int, *, multi_login: bool, **kwargs) -> AccessToken:
"""
生成加密 token
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:param kwargs: token 额外信息
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
session_uuid = str(uuid.uuid4())
access_token = jwt_encode({
'session_uuid': session_uuid,
'exp': timezone.to_utc(expire).timestamp(),
'sub': str(user_id),
})
if not multi_login:
await redis_client.delete_by_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}')
await redis_client.set(
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}',
access_token,
ex=settings.TOKEN_EXPIRE_SECONDS,
)
# Token 附加信息单独存储
if kwargs:
await redis_client.set(
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}',
json.dumps(kwargs, ensure_ascii=False),
ex=settings.TOKEN_EXPIRE_SECONDS,
)
return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid)
async def create_refresh_token(session_uuid: str, user_id: int, *, multi_login: bool) -> RefreshToken:
"""
生成加密刷新 token仅用于创建新的 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_REFRESH_EXPIRE_SECONDS)
refresh_token = jwt_encode({
'session_uuid': session_uuid,
'exp': timezone.to_utc(expire).timestamp(),
'sub': str(user_id),
})
if not multi_login:
await redis_client.delete_by_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}')
await redis_client.set(
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}',
refresh_token,
ex=settings.TOKEN_REFRESH_EXPIRE_SECONDS,
)
return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire)
async def create_new_token(
refresh_token: str,
session_uuid: str,
user_id: int,
*,
multi_login: bool,
**kwargs,
) -> NewToken:
"""
生成新的 token
:param refresh_token: 刷新 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:param kwargs: token 附加信息
:return:
"""
redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
if not redis_refresh_token or redis_refresh_token != refresh_token:
raise errors.TokenError(msg='Refresh Token 已过期,请重新登录')
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
new_access_token = await create_access_token(user_id, multi_login=multi_login, **kwargs)
new_refresh_token = await create_refresh_token(new_access_token.session_uuid, user_id, multi_login=multi_login)
return NewToken(
new_access_token=new_access_token.access_token,
new_access_token_expire_time=new_access_token.access_token_expire_time,
new_refresh_token=new_refresh_token.refresh_token,
new_refresh_token_expire_time=new_refresh_token.refresh_token_expire_time,
session_uuid=new_access_token.session_uuid,
)
async def revoke_token(user_id: int, session_uuid: str) -> None:
"""
撤销 token
:param user_id: 用户 ID
:param session_uuid: 会话 ID
:return:
"""
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}')
await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}')
def get_token(request: Request) -> str:
"""
获取请求头中的 token
:param request: FastAPI 请求对象
:return:
"""
authorization = request.headers.get('Authorization')
scheme, token = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != 'bearer':
raise errors.TokenError(msg='Token 无效')
return token
async def get_current_user(db: AsyncSession, pk: int) -> User: async def get_current_user(db: AsyncSession, pk: int) -> User:
""" """
获取当前用户 获取当前用户
@@ -219,16 +88,17 @@ async def get_jwt_user(user_id: int) -> GetUserInfoWithRelationDetail:
""" """
获取 JWT 用户 获取 JWT 用户
:param user_id: :param user_id: 用户 ID
:return: :return:
""" """
cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') user_key = f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}'
cache_user = await redis_client.get(user_key)
if not cache_user: if not cache_user:
async with async_db_session() as db: async with async_db_session() as db:
current_user = await get_current_user(db, user_id) current_user = await get_current_user(db, user_id)
user = GetUserInfoWithRelationDetail.model_validate(current_user) user = GetUserInfoWithRelationDetail.model_validate(current_user)
await redis_client.set( await redis_client.set(
f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}', user_key,
user.model_dump_json(), user.model_dump_json(),
ex=settings.TOKEN_EXPIRE_SECONDS, ex=settings.TOKEN_EXPIRE_SECONDS,
) )
@@ -251,7 +121,6 @@ async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{ctx.user_id}:{token_payload.session_uuid}') redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{ctx.user_id}:{token_payload.session_uuid}')
if not redis_token: if not redis_token:
raise errors.TokenError(msg='Token 已过期') raise errors.TokenError(msg='Token 已过期')
if token != redis_token: if token != redis_token:
raise errors.TokenError(msg='Token 已失效') raise errors.TokenError(msg='Token 已失效')
@@ -292,7 +161,6 @@ def superuser_verify(request: Request, _token: str = DependsJwtAuth) -> bool:
""" """
if isinstance(request.user, UnauthenticatedUser): if isinstance(request.user, UnauthenticatedUser):
raise errors.TokenError raise errors.TokenError
superuser = request.user.is_superuser superuser = request.user.is_superuser
if not superuser or not request.user.is_staff: if not superuser or not request.user.is_staff:
raise errors.AuthorizationError raise errors.AuthorizationError
+329
View File
@@ -0,0 +1,329 @@
import asyncio
import json
import uuid
from datetime import timedelta
from typing import Any
from fastapi import Request
from fastapi.security.utils import get_authorization_scheme_param
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken
from backend.common.exception import errors
from backend.common.security.jwt import jwt_encode
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.timezone import timezone
_socket_disconnect_tasks: set[asyncio.Task[None]] = set()
_REDIS_BATCH_SIZE = 1000
def get_token(request: Request) -> str:
"""
获取请求头中的 token
:param request: FastAPI 请求对象
:return:
"""
authorization = request.headers.get('Authorization')
scheme, token = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != 'bearer':
raise errors.TokenError(msg='Token 无效')
return token
async def _srem_members(key: str, members: list[str] | set[str]) -> None:
"""
分批从集合中移除成员
:param key: 集合 key
:param members: 要移除的成员
:return:
"""
ordered = list(members)
if not ordered:
return
for index in range(0, len(ordered), _REDIS_BATCH_SIZE):
await redis_client.srem(key, *ordered[index : index + _REDIS_BATCH_SIZE])
async def get_user_sessions(user_id: int) -> set[str]:
"""
读取有效正式会话
:param user_id: 用户 ID
:return:
"""
index_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}'
users_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users'
swagger_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}:swagger'
indexed = set(await redis_client.smembers(index_key))
live_sessions: set[str] = set()
swagger_sessions: set[str] = set()
if indexed:
ordered = list(indexed)
access_tokens, refresh_tokens, extras = await asyncio.gather(
redis_client.mget_batched([
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}' for session_uuid in ordered
]),
redis_client.mget_batched([
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}' for session_uuid in ordered
]),
redis_client.mget_batched([
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}' for session_uuid in ordered
]),
)
for session_uuid, access, refresh, extra in zip(ordered, access_tokens, refresh_tokens, extras, strict=True):
extra_info = None
if extra:
try:
extra_info = json.loads(extra)
except (json.JSONDecodeError, TypeError):
extra_info = None
if isinstance(extra_info, dict) and extra_info.get('swagger') is not None:
swagger_sessions.add(session_uuid)
continue
if access or refresh:
live_sessions.add(session_uuid)
if swagger_sessions:
await _srem_members(index_key, swagger_sessions)
async with redis_client.pipeline(transaction=False) as pipe:
pipe.sadd(swagger_key, *swagger_sessions)
pipe.expire(swagger_key, settings.TOKEN_EXPIRE_SECONDS)
await pipe.execute()
drop_from_index = indexed - live_sessions
session_ttl = max(settings.TOKEN_EXPIRE_SECONDS, settings.TOKEN_REFRESH_EXPIRE_SECONDS)
async with redis_client.pipeline(transaction=False) as pipe:
if drop_from_index:
pipe.srem(index_key, *drop_from_index)
if live_sessions:
pipe.expire(index_key, session_ttl)
pipe.sadd(users_key, str(user_id))
else:
pipe.delete(index_key)
pipe.srem(users_key, str(user_id))
await pipe.execute()
return live_sessions
async def create_access_token(
user_id: int,
*,
multi_login: bool,
session_uuid: str | None = None,
swagger: bool = False,
**kwargs: Any,
) -> AccessToken:
"""
生成加密 token
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:param session_uuid: 复用已有会话 UUID刷新令牌时保持在线状态连续
:param swagger: 是否为 swagger 调试 token不写入会话索引且不踢其他端
:param kwargs: token 额外信息
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
session_uuid = session_uuid or str(uuid.uuid4())
access_token = jwt_encode({
'session_uuid': session_uuid,
'jti': str(uuid.uuid4()),
'exp': timezone.to_utc(expire).timestamp(),
'sub': str(user_id),
})
if not swagger and not multi_login:
await revoke_user_tokens(user_id, exclude_session_uuid=session_uuid, include_swagger=False)
extra_info = {'swagger': True, **kwargs} if swagger else kwargs
extra_ttl = (
settings.TOKEN_EXPIRE_SECONDS
if swagger
else max(settings.TOKEN_EXPIRE_SECONDS, settings.TOKEN_REFRESH_EXPIRE_SECONDS)
)
session_ttl = max(settings.TOKEN_EXPIRE_SECONDS, settings.TOKEN_REFRESH_EXPIRE_SECONDS)
async with redis_client.pipeline(transaction=False) as pipe:
pipe.set(
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}',
access_token,
ex=settings.TOKEN_EXPIRE_SECONDS,
)
if extra_info:
pipe.set(
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}',
json.dumps(extra_info, ensure_ascii=False),
ex=extra_ttl,
)
if swagger:
swagger_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}:swagger'
pipe.sadd(swagger_key, session_uuid)
pipe.expire(swagger_key, settings.TOKEN_EXPIRE_SECONDS)
else:
index_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}'
pipe.sadd(index_key, session_uuid)
pipe.expire(index_key, session_ttl)
pipe.sadd(
f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users',
str(user_id),
)
await pipe.execute()
return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid)
async def create_refresh_token(session_uuid: str, user_id: int, *, multi_login: bool) -> RefreshToken:
"""
生成加密刷新 token仅用于创建新的 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_REFRESH_EXPIRE_SECONDS)
refresh_token = jwt_encode({
'session_uuid': session_uuid,
'jti': str(uuid.uuid4()),
'exp': timezone.to_utc(expire).timestamp(),
'sub': str(user_id),
})
session_ttl = max(settings.TOKEN_EXPIRE_SECONDS, settings.TOKEN_REFRESH_EXPIRE_SECONDS)
index_key = f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}'
async with redis_client.pipeline(transaction=False) as pipe:
pipe.set(
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}',
refresh_token,
ex=settings.TOKEN_REFRESH_EXPIRE_SECONDS,
)
pipe.sadd(index_key, session_uuid)
pipe.expire(index_key, session_ttl)
pipe.sadd(f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users', str(user_id))
await pipe.execute()
return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire)
async def create_new_token(
refresh_token: str,
session_uuid: str,
user_id: int,
*,
multi_login: bool,
**kwargs: Any,
) -> NewToken:
"""
生成新的 token
:param refresh_token: 刷新 token
:param session_uuid: 会话 UUID
:param user_id: 用户 ID
:param multi_login: 是否允许多端登录
:param kwargs: token 附加信息
:return:
"""
redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}')
if not redis_refresh_token or redis_refresh_token != refresh_token:
raise errors.TokenError(msg='Refresh Token 已过期,请重新登录')
new_access_token = await create_access_token(
user_id,
multi_login=multi_login,
session_uuid=session_uuid,
**kwargs,
)
new_refresh_token = await create_refresh_token(session_uuid, user_id, multi_login=multi_login)
return NewToken(
new_access_token=new_access_token.access_token,
new_access_token_expire_time=new_access_token.access_token_expire_time,
new_refresh_token=new_refresh_token.refresh_token,
new_refresh_token_expire_time=new_refresh_token.refresh_token_expire_time,
session_uuid=session_uuid,
)
async def _revoke_sessions(user_id: int, session_uuids: set[str]) -> None:
"""
批量删除会话相关 key并异步断开 socket
:param user_id: 用户 ID
:param session_uuids: 要撤销的会话 UUID
:return:
"""
if not session_uuids:
return
ordered = list(session_uuids)
sid_sets = await redis_client.smembers_many([
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}' for session_uuid in ordered
])
sids = [sid for members in sid_sets for sid in members]
delete_keys: list[str] = []
for session_uuid in ordered:
delete_keys.extend((
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}',
f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}',
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}',
))
delete_keys.extend(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}' for sid in sids)
await redis_client.delete_batched(delete_keys)
await asyncio.gather(
_srem_members(
f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}',
ordered,
),
_srem_members(
f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}:swagger',
ordered,
),
)
if not await redis_client.smembers(f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}'):
await redis_client.srem(f'{settings.TOKEN_SESSION_REDIS_PREFIX}:users', str(user_id))
if sids:
from backend.common.socketio.server import sio
for sid in sids:
for namespace in ('/', '/ws'):
task = sio.start_background_task(sio.disconnect, sid, namespace=namespace)
_socket_disconnect_tasks.add(task)
task.add_done_callback(_socket_disconnect_tasks.discard)
async def revoke_token(user_id: int, session_uuid: str) -> None:
"""
撤销 token
:param user_id: 用户 ID
:param session_uuid: 会话 ID
:return:
"""
await _revoke_sessions(user_id, {session_uuid})
async def revoke_user_tokens(
user_id: int,
*,
exclude_session_uuid: str | None = None,
include_swagger: bool = True,
) -> None:
"""
撤销用户全部会话可保留当前会话
:param user_id: 用户 ID
:param exclude_session_uuid: 需要保留的会话 UUID
:param include_swagger: 是否同时撤销 swagger 调试 token
:return:
"""
session_uuids = await get_user_sessions(user_id)
if include_swagger:
session_uuids |= set(await redis_client.smembers(f'{settings.TOKEN_SESSION_REDIS_PREFIX}:{user_id}:swagger'))
if exclude_session_uuid:
session_uuids.discard(exclude_session_uuid)
await _revoke_sessions(user_id, session_uuids)
+65 -24
View File
@@ -1,14 +1,17 @@
import urllib.parse import urllib.parse
import uuid import uuid
from typing import Any
import socketio import socketio
from starlette_context import request_cycle_context from starlette_context import request_cycle_context
from backend.common.log import log from backend.common.log import log
from backend.common.security.jwt import jwt_authentication from backend.common.security.jwt import jwt_authentication, jwt_decode
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.timezone import timezone
# 创建 Socket.IO 服务器实例 # 创建 Socket.IO 服务器实例
sio = socketio.AsyncServer( sio = socketio.AsyncServer(
@@ -22,20 +25,29 @@ sio = socketio.AsyncServer(
async_mode='asgi', async_mode='asgi',
cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS, cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS,
cors_credentials=True, cors_credentials=True,
namespaces=['/ws'], namespaces=['/', '/ws'],
) )
@sio.event @sio.event(namespace='*')
async def connect(sid, environ, auth) -> bool: async def connect(namespace: str, sid: str, _environ: dict[str, Any], auth: dict[str, Any] | None) -> bool:
"""Socket 连接事件""" """
if not auth: Socket 连接事件
:param namespace: 命名空间
:param sid: 连接 ID
:param _environ: 连接环境
:param auth: 授权信息
:return:
"""
if namespace not in sio.namespaces:
return False
if not isinstance(auth, dict):
log.error('WebSocket 连接失败:无授权') log.error('WebSocket 连接失败:无授权')
return False return False
session_uuid = auth.get('session_uuid') session_uuid = auth.get('session_uuid')
token = auth.get('token') token = auth.get('token')
if not token or not session_uuid: if not isinstance(token, str) or not token or not isinstance(session_uuid, str) or not session_uuid:
log.error('WebSocket 连接失败:授权失败,请检查') log.error('WebSocket 连接失败:授权失败,请检查')
return False return False
@@ -44,34 +56,63 @@ async def connect(sid, environ, auth) -> bool:
if settings.ENVIRONMENT == 'prod': if settings.ENVIRONMENT == 'prod':
log.error('WebSocket 连接失败:生产环境禁止免授权直连') log.error('WebSocket 连接失败:生产环境禁止免授权直连')
return False return False
await redis_client.set(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}', session_uuid) expire = settings.TOKEN_EXPIRE_SECONDS
await redis_client.sadd(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}', sid) else:
await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid)
return True
try: try:
with request_cycle_context({settings.TRACE_ID_REQUEST_HEADER_KEY: uuid.uuid4().hex}): with request_cycle_context({settings.TRACE_ID_REQUEST_HEADER_KEY: uuid.uuid4().hex}):
await jwt_authentication(token) await jwt_authentication(token)
token_payload = jwt_decode(token)
except Exception as e: except Exception as e:
log.info(f'WebSocket 连接失败:{e!s}') log.info(f'WebSocket 连接失败:{e!s}')
return False return False
session_uuid = token_payload.session_uuid
expire = int((token_payload.expire_time - timezone.now()).total_seconds())
if expire <= 0:
log.info('WebSocket 连接失败:Token 已过期')
return False
await redis_client.set(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}', session_uuid) await sio.save_session(sid, {'session_uuid': session_uuid}, namespace=namespace)
await redis_client.sadd(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}', sid) sid_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}'
await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid) session_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}'
await redis_client.set(sid_key, session_uuid, ex=expire)
await redis_client.sadd(session_key, sid)
session_ttl = await redis_client.ttl(session_key)
# 新集合尚无 TTL 时按本次连接设置;多连接取最长剩余寿命
new_ttl = expire if session_ttl < 0 else max(session_ttl, expire)
await redis_client.expire(session_key, new_ttl)
return True return True
@sio.event @sio.event(namespace='*')
async def disconnect(sid) -> None: async def disconnect(namespace: str, sid: str, _reason: str | None = None) -> None:
"""Socket 断开连接事件""" """
session_uuid = await redis_client.get(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}') Socket 断开连接事件
:param namespace: 命名空间
:param sid: 连接 ID
:param _reason: 断开原因
:return:
"""
sid_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}'
session_uuid = await redis_client.get(sid_key)
if not session_uuid:
try:
session_data = await sio.get_session(sid, namespace=namespace)
except KeyError:
return
session_uuid = session_data.get('session_uuid')
if not session_uuid: if not session_uuid:
return return
session_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}' session_key = f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:session:{session_uuid}'
await redis_client.delete(f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{sid}') await redis_client.delete(sid_key)
await redis_client.srem(session_key, sid) await redis_client.srem(session_key, sid)
if await redis_client.scard(session_key) == 0: remaining = list(await redis_client.smembers(session_key))
await redis_client.delete(session_key) if not remaining:
await redis_client.srem(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid) return
mappings = await redis_client.mget_batched([
f'{settings.TOKEN_ONLINE_REDIS_PREFIX}:sid:{other_sid}' for other_sid in remaining
])
stale_sids = [other_sid for other_sid, mapping in zip(remaining, mappings, strict=True) if mapping != session_uuid]
if stale_sids:
await redis_client.srem(session_key, *stale_sids)
+3 -2
View File
@@ -27,7 +27,7 @@ def client() -> Generator:
@pytest.fixture(scope='module') @pytest.fixture(scope='module')
def token_headers(client: TestClient) -> dict[str, str]: def token_headers(client: TestClient) -> Generator[dict[str, str], None, None]:
params = { params = {
'username': PYTEST_USERNAME, 'username': PYTEST_USERNAME,
'password': PYTEST_PASSWORD, 'password': PYTEST_PASSWORD,
@@ -37,4 +37,5 @@ def token_headers(client: TestClient) -> dict[str, str]:
token_type = response.json()['token_type'] token_type = response.json()['token_type']
access_token = response.json()['access_token'] access_token = response.json()['access_token']
headers = {'Authorization': f'{token_type} {access_token}'} headers = {'Authorization': f'{token_type} {access_token}'}
return headers yield headers
client.post('/auth/logout', headers=headers)
+4
View File
@@ -68,6 +68,8 @@ class Settings(BaseSettings):
# Redis # Redis
REDIS_TIMEOUT: int = 5 REDIS_TIMEOUT: int = 5
REDIS_MAX_CONNECTIONS: int = 100 # 连接池上限
REDIS_POOL_TIMEOUT: int = 20 # 等待空闲连接超时(秒)
# 缓存 # 缓存
CACHE_LOCAL_ENABLED: bool = True CACHE_LOCAL_ENABLED: bool = True
@@ -79,6 +81,7 @@ class Settings(BaseSettings):
CACHE_PUBSUB_CHANNEL: str = 'fba:cache:invalidate' CACHE_PUBSUB_CHANNEL: str = 'fba:cache:invalidate'
CACHE_PUBSUB_RECONNECT_DELAY: int = 5 # 重连延迟(秒) CACHE_PUBSUB_RECONNECT_DELAY: int = 5 # 重连延迟(秒)
CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS: int = 10 # 最大重连次数 CACHE_PUBSUB_MAX_RECONNECT_ATTEMPTS: int = 10 # 最大重连次数
CACHE_PUBSUB_POLL_TIMEOUT: float = 1.0 # 订阅消息轮询超时(秒)
# .env Snowflake # .env Snowflake
SNOWFLAKE_ENABLED: bool = False SNOWFLAKE_ENABLED: bool = False
@@ -101,6 +104,7 @@ class Settings(BaseSettings):
TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'fba:token_extra_info' TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'fba:token_extra_info'
TOKEN_ONLINE_REDIS_PREFIX: str = 'fba:token_online' TOKEN_ONLINE_REDIS_PREFIX: str = 'fba:token_online'
TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:refresh_token' TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:refresh_token'
TOKEN_SESSION_REDIS_PREFIX: str = 'fba:token_session'
TOKEN_REQUEST_UNDERLYING_SECURITY: bool = True TOKEN_REQUEST_UNDERLYING_SECURITY: bool = True
TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单 TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单
f'{FASTAPI_API_V1_PATH}/auth/login', f'{FASTAPI_API_V1_PATH}/auth/login',
+85 -9
View File
@@ -1,6 +1,6 @@
import sys import sys
from redis.asyncio import Redis from redis.asyncio import BlockingConnectionPool, Redis
from redis.exceptions import AuthenticationError, TimeoutError from redis.exceptions import AuthenticationError, TimeoutError
from backend.common.log import log from backend.common.log import log
@@ -22,6 +22,8 @@ class RedisCli(Redis):
socket_keepalive: bool = True, socket_keepalive: bool = True,
health_check_interval: int = 30, health_check_interval: int = 30,
decode_responses: bool = True, decode_responses: bool = True,
max_connections: int = settings.REDIS_MAX_CONNECTIONS,
pool_timeout: int = settings.REDIS_POOL_TIMEOUT,
) -> None: ) -> None:
""" """
初始化 Redis 客户端 初始化 Redis 客户端
@@ -35,8 +37,12 @@ class RedisCli(Redis):
:param socket_keepalive: 是否开启 TCP Keepalive 探测 :param socket_keepalive: 是否开启 TCP Keepalive 探测
:param health_check_interval: 健康检查间隔时间 :param health_check_interval: 健康检查间隔时间
:param decode_responses: 是否自动将 Redis 返回的字节流bytes解码为字符串utf-8 :param decode_responses: 是否自动将 Redis 返回的字节流bytes解码为字符串utf-8
:param max_connections: 连接池最大连接数超出后排队等待而不是无限新建
:param pool_timeout: 等待空闲连接的超时时间
""" """
super().__init__( pool = BlockingConnectionPool(
max_connections=max_connections,
timeout=pool_timeout,
host=host, host=host,
port=port, port=port,
password=password, password=password,
@@ -47,6 +53,9 @@ class RedisCli(Redis):
health_check_interval=health_check_interval, health_check_interval=health_check_interval,
decode_responses=decode_responses, decode_responses=decode_responses,
) )
super().__init__(connection_pool=pool)
# 连接池由客户端独占,aclose 时一并释放
self.auto_close_connection_pool = True
async def init(self) -> None: async def init(self) -> None:
"""初始化 Redis 服务器""" """初始化 Redis 服务器"""
@@ -67,13 +76,15 @@ class RedisCli(Redis):
key_prefix: str, key_prefix: str,
exclude_keys: str | list[str] | None = None, exclude_keys: str | list[str] | None = None,
batch_size: int = 1000, batch_size: int = 1000,
count: int = 1000,
) -> None: ) -> None:
""" """
删除指定前缀的所有 key 删除指定前缀的所有 key
:param key_prefix: 要删除的键前缀 :param key_prefix: 要删除的键前缀
:param exclude_keys: 要排除的键或键列表 :param exclude_keys: 要排除的键或键列表
:param batch_size: 批量删除的大小避免一次性删除过多键导致 Redis 阻塞 :param batch_size: 批量删除的大小
:param count: 每次扫描批次的数量
:return: :return:
""" """
exclude_set = ( exclude_set = (
@@ -84,22 +95,18 @@ class RedisCli(Redis):
else set() else set()
) )
batch_keys = [] batch_keys = []
if key_prefix not in exclude_set and await self.exists(key_prefix): if key_prefix not in exclude_set and await self.exists(key_prefix):
batch_keys.append(key_prefix) batch_keys.append(key_prefix)
async for key in self.scan_iter(match=f'{key_prefix}:*', count=count):
async for key in self.scan_iter(match=f'{key_prefix}:*'):
if key not in exclude_set: if key not in exclude_set:
batch_keys.append(key) batch_keys.append(key)
if len(batch_keys) >= batch_size: if len(batch_keys) >= batch_size:
await self.delete(*batch_keys) await self.delete(*batch_keys)
batch_keys.clear() batch_keys.clear()
if batch_keys: if batch_keys:
await self.delete(*batch_keys) await self.delete(*batch_keys)
async def get_by_prefix(self, key_prefix: str, count: int = 100) -> list[str]: async def get_by_prefix(self, key_prefix: str, count: int = 1000) -> list[str]:
""" """
获取指定前缀的所有 key 获取指定前缀的所有 key
@@ -109,6 +116,75 @@ class RedisCli(Redis):
""" """
return [key async for key in self.scan_iter(match=f'{key_prefix}:*', count=count)] return [key async for key in self.scan_iter(match=f'{key_prefix}:*', count=count)]
async def mget_batched(self, keys: list[str], batch_size: int = 1000) -> list[str | None]:
"""
分批获取多个 key 的值
:param keys: 键列表
:param batch_size: 每批数量
:return:
"""
if batch_size <= 0:
raise ValueError('batch_size 必须大于 0')
if not keys:
return []
values: list[str | None] = []
for index in range(0, len(keys), batch_size):
values.extend(await self.mget(keys[index : index + batch_size]))
return values
async def exists_batched(self, keys: list[str], batch_size: int = 1000) -> list[bool]:
"""
分批判断多个 key 是否存在
:param keys: 键列表
:param batch_size: 每批数量
:return:
"""
if batch_size <= 0:
raise ValueError('batch_size 必须大于 0')
return [value is not None for value in await self.mget_batched(keys, batch_size=batch_size)]
async def smembers_many(self, keys: list[str], batch_size: int = 100) -> list[set[str]]:
"""
分批获取多个集合的成员
:param keys: 键列表
:param batch_size: 每批数量
:return:
"""
if batch_size <= 0:
raise ValueError('batch_size 必须大于 0')
if not keys:
return []
members: list[set[str]] = []
for index in range(0, len(keys), batch_size):
batch = keys[index : index + batch_size]
async with self.pipeline(transaction=False) as pipe:
for key in batch:
pipe.smembers(key)
results = await pipe.execute()
members.extend(set(result) if result else set() for result in results)
return members
async def delete_batched(self, keys: list[str], batch_size: int = 1000) -> int:
"""
分批删除多个 key
:param keys: 键列表
:param batch_size: 每批数量
:return:
"""
if batch_size <= 0:
raise ValueError('batch_size 必须大于 0')
if not keys:
return 0
deleted = 0
for index in range(0, len(keys), batch_size):
batch = keys[index : index + batch_size]
deleted += await self.delete(*batch)
return deleted
# 创建 redis 客户端单例 # 创建 redis 客户端单例
redis_client: RedisCli = RedisCli() redis_client: RedisCli = RedisCli()
+5 -1
View File
@@ -19,7 +19,7 @@
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME = 'fba_generator' CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME = 'fba_generator'
``` ```
`backend/core/conf.py` 中添加以下内容 当前项目的 `backend/core/conf.py` 已包含以下字段
```python ```python
################################################## ##################################################
@@ -28,6 +28,10 @@ CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME = 'fba_generator'
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str
``` ```
## 配置项说明
- `CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME`:控制代码生成结果下载压缩包的文件名
## 使用方式 ## 使用方式
1. 安装并启用插件后,重启后端服务 1. 安装并启用插件后,重启后端服务
+2 -2
View File
@@ -2,11 +2,11 @@ from fastapi import APIRouter
from backend.core.conf import settings from backend.core.conf import settings
from backend.plugin.code_generator.api.v1.business import router as business_router from backend.plugin.code_generator.api.v1.business import router as business_router
from backend.plugin.code_generator.api.v1.code_gen import router as code_gen_router
from backend.plugin.code_generator.api.v1.column import router as column_router from backend.plugin.code_generator.api.v1.column import router as column_router
from backend.plugin.code_generator.api.v1.gen import router as gen_router
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/code-generation', tags=['代码生成']) v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/code-generation', tags=['代码生成'])
v1.include_router(business_router, prefix='/businesses') v1.include_router(business_router, prefix='/businesses')
v1.include_router(column_router, prefix='/columns') v1.include_router(column_router, prefix='/columns')
v1.include_router(gen_router, prefix='/generations') v1.include_router(code_gen_router, prefix='/generations')
@@ -9,20 +9,20 @@ 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 from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.code_generator.schema.business import ( from backend.plugin.code_generator.schema.business import (
CreateGenBusinessParam, CreateCodeGenBusinessParam,
GetGenBusinessDetail, GetCodeGenBusinessDetail,
UpdateGenBusinessParam, UpdateCodeGenBusinessParam,
) )
from backend.plugin.code_generator.schema.column import GetGenColumnDetail from backend.plugin.code_generator.schema.column import GetCodeGenColumnDetail
from backend.plugin.code_generator.service.business_service import gen_business_service from backend.plugin.code_generator.service.business_service import code_gen_business_service
from backend.plugin.code_generator.service.column_service import gen_column_service from backend.plugin.code_generator.service.column_service import code_gen_column_service
router = APIRouter() router = APIRouter()
@router.get('/all', summary='获取所有代码生成业务', dependencies=[DependsJwtAuth]) @router.get('/all', summary='获取所有代码生成业务', dependencies=[DependsJwtAuth])
async def get_all_businesses(db: CurrentSession) -> ResponseSchemaModel[list[GetGenBusinessDetail]]: async def get_all_businesses(db: CurrentSession) -> ResponseSchemaModel[list[GetCodeGenBusinessDetail]]:
data = await gen_business_service.get_all(db=db) data = await code_gen_business_service.get_all(db=db)
return response_base.success(data=data) return response_base.success(data=data)
@@ -30,8 +30,8 @@ async def get_all_businesses(db: CurrentSession) -> ResponseSchemaModel[list[Get
async def get_business( async def get_business(
db: CurrentSession, db: CurrentSession,
pk: Annotated[int, Path(description='业务 ID')], pk: Annotated[int, Path(description='业务 ID')],
) -> ResponseSchemaModel[GetGenBusinessDetail]: ) -> ResponseSchemaModel[GetCodeGenBusinessDetail]:
data = await gen_business_service.get(db=db, pk=pk) data = await code_gen_business_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -46,8 +46,8 @@ async def get_business(
async def get_businesses_paginated( async def get_businesses_paginated(
db: CurrentSession, db: CurrentSession,
table_name: Annotated[str | None, Query(description='代码生成业务表名称')] = None, table_name: Annotated[str | None, Query(description='代码生成业务表名称')] = None,
) -> ResponseSchemaModel[PageData[GetGenBusinessDetail]]: ) -> ResponseSchemaModel[PageData[GetCodeGenBusinessDetail]]:
page_data = await gen_business_service.get_list(db=db, table_name=table_name) page_data = await code_gen_business_service.get_list(db=db, table_name=table_name)
return response_base.success(data=page_data) return response_base.success(data=page_data)
@@ -55,8 +55,8 @@ async def get_businesses_paginated(
async def get_business_all_columns( async def get_business_all_columns(
db: CurrentSession, db: CurrentSession,
pk: Annotated[int, Path(description='业务 ID')], pk: Annotated[int, Path(description='业务 ID')],
) -> ResponseSchemaModel[list[GetGenColumnDetail]]: ) -> ResponseSchemaModel[list[GetCodeGenColumnDetail]]:
data = await gen_column_service.get_columns(db=db, business_id=pk) data = await code_gen_column_service.get_columns(db=db, business_id=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -68,8 +68,8 @@ async def get_business_all_columns(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_business(db: CurrentSessionTransaction, obj: CreateGenBusinessParam) -> ResponseModel: async def create_business(db: CurrentSessionTransaction, obj: CreateCodeGenBusinessParam) -> ResponseModel:
await gen_business_service.create(db=db, obj=obj) await code_gen_business_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -84,9 +84,9 @@ async def create_business(db: CurrentSessionTransaction, obj: CreateGenBusinessP
async def update_business( async def update_business(
db: CurrentSessionTransaction, db: CurrentSessionTransaction,
pk: Annotated[int, Path(description='业务 ID')], pk: Annotated[int, Path(description='业务 ID')],
obj: UpdateGenBusinessParam, obj: UpdateCodeGenBusinessParam,
) -> ResponseModel: ) -> ResponseModel:
count = await gen_business_service.update(db=db, pk=pk, obj=obj) count = await code_gen_business_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,7 +103,7 @@ async def update_business(
async def delete_business( async def delete_business(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='业务 ID')] db: CurrentSessionTransaction, pk: Annotated[int, Path(description='业务 ID')]
) -> ResponseModel: ) -> ResponseModel:
count = await gen_business_service.delete(db=db, pk=pk) count = await code_gen_business_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()
@@ -9,8 +9,8 @@ from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC from backend.common.security.rbac import DependsRBAC
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.code_generator.schema.gen import ImportParam from backend.plugin.code_generator.schema.code_gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service from backend.plugin.code_generator.service.code_gen_service import code_gen_service
router = APIRouter() router = APIRouter()
@@ -20,7 +20,7 @@ async def get_all_tables(
db: CurrentSession, db: CurrentSession,
table_schema: Annotated[str, Query(description='数据库名')] = 'fba', table_schema: Annotated[str, Query(description='数据库名')] = 'fba',
) -> ResponseSchemaModel[list[dict[str, str | None]]]: ) -> ResponseSchemaModel[list[dict[str, str | None]]]:
data = await gen_service.get_tables(db=db, table_schema=table_schema) data = await code_gen_service.get_tables(db=db, table_schema=table_schema)
return response_base.success(data=data) return response_base.success(data=data)
@@ -33,7 +33,7 @@ async def get_all_tables(
], ],
) )
async def import_table(db: CurrentSessionTransaction, obj: ImportParam) -> ResponseModel: async def import_table(db: CurrentSessionTransaction, obj: ImportParam) -> ResponseModel:
await gen_service.import_business_and_model(db=db, obj=obj) await code_gen_service.import_business_and_model(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -41,7 +41,7 @@ async def import_table(db: CurrentSessionTransaction, obj: ImportParam) -> Respo
async def preview_code( async def preview_code(
db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')] db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]
) -> ResponseSchemaModel[dict[str, bytes]]: ) -> ResponseSchemaModel[dict[str, bytes]]:
data = await gen_service.preview(db=db, pk=pk) data = await code_gen_service.preview(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -49,7 +49,7 @@ async def preview_code(
async def get_generate_paths( async def get_generate_paths(
db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')] db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]
) -> ResponseSchemaModel[list[str]]: ) -> ResponseSchemaModel[list[str]]:
data = await gen_service.get_generate_path(db=db, pk=pk) data = await code_gen_service.get_generate_path(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -63,13 +63,13 @@ async def get_generate_paths(
], ],
) )
async def generate_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]) -> ResponseModel: async def generate_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]) -> ResponseModel:
await gen_service.generate(db=db, pk=pk) await code_gen_service.generate(db=db, pk=pk)
return response_base.success() return response_base.success()
@router.get('/{pk}', summary='下载代码', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='下载代码', dependencies=[DependsJwtAuth])
async def download_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]): # ruff:ignore[missing-return-type-undocumented-public-function] async def download_code(db: CurrentSession, pk: Annotated[int, Path(description='业务 ID')]): # ruff:ignore[missing-return-type-undocumented-public-function]
bio = await gen_service.download(db=db, pk=pk) bio = await code_gen_service.download(db=db, pk=pk)
return StreamingResponse( return StreamingResponse(
bio, bio,
media_type='application/x-zip-compressed', media_type='application/x-zip-compressed',
+12 -12
View File
@@ -8,26 +8,26 @@ 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 from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.code_generator.schema.column import ( from backend.plugin.code_generator.schema.column import (
CreateGenColumnParam, CreateCodeGenColumnParam,
GetGenColumnDetail, GetCodeGenColumnDetail,
UpdateGenColumnParam, UpdateCodeGenColumnParam,
) )
from backend.plugin.code_generator.service.column_service import gen_column_service from backend.plugin.code_generator.service.column_service import code_gen_column_service
router = APIRouter() router = APIRouter()
@router.get('/types', summary='获取代码生成模型列类型', dependencies=[DependsJwtAuth]) @router.get('/types', summary='获取代码生成模型列类型', dependencies=[DependsJwtAuth])
async def get_column_types() -> ResponseSchemaModel[list[str]]: async def get_column_types() -> ResponseSchemaModel[list[str]]:
column_types = await gen_column_service.get_types() column_types = await code_gen_column_service.get_types()
return response_base.success(data=column_types) return response_base.success(data=column_types)
@router.get('/{pk}', summary='获取代码生成模型列详情', dependencies=[DependsJwtAuth]) @router.get('/{pk}', summary='获取代码生成模型列详情', dependencies=[DependsJwtAuth])
async def get_column( async def get_column(
db: CurrentSession, pk: Annotated[int, Path(description='模型列 ID')] db: CurrentSession, pk: Annotated[int, Path(description='模型列 ID')]
) -> ResponseSchemaModel[GetGenColumnDetail]: ) -> ResponseSchemaModel[GetCodeGenColumnDetail]:
data = await gen_column_service.get(db=db, pk=pk) data = await code_gen_column_service.get(db=db, pk=pk)
return response_base.success(data=data) return response_base.success(data=data)
@@ -39,8 +39,8 @@ async def get_column(
DependsRBAC, DependsRBAC,
], ],
) )
async def create_column(db: CurrentSessionTransaction, obj: CreateGenColumnParam) -> ResponseModel: async def create_column(db: CurrentSessionTransaction, obj: CreateCodeGenColumnParam) -> ResponseModel:
await gen_column_service.create(db=db, obj=obj) await code_gen_column_service.create(db=db, obj=obj)
return response_base.success() return response_base.success()
@@ -53,9 +53,9 @@ async def create_column(db: CurrentSessionTransaction, obj: CreateGenColumnParam
], ],
) )
async def update_column( async def update_column(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='模型列 ID')], obj: UpdateGenColumnParam db: CurrentSessionTransaction, pk: Annotated[int, Path(description='模型列 ID')], obj: UpdateCodeGenColumnParam
) -> ResponseModel: ) -> ResponseModel:
count = await gen_column_service.update(db=db, pk=pk, obj=obj) count = await code_gen_column_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()
@@ -72,7 +72,7 @@ async def update_column(
async def delete_column( async def delete_column(
db: CurrentSessionTransaction, pk: Annotated[int, Path(description='模型列 ID')] db: CurrentSessionTransaction, pk: Annotated[int, Path(description='模型列 ID')]
) -> ResponseModel: ) -> ResponseModel:
count = await gen_column_service.delete(db=db, pk=pk) count = await code_gen_column_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()
@@ -4,15 +4,15 @@ from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.code_generator.model import GenBusiness from backend.plugin.code_generator.model import CodeGenBusiness
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam, UpdateGenBusinessParam from backend.plugin.code_generator.schema.business import CreateCodeGenBusinessParam, UpdateCodeGenBusinessParam
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
class CRUDGenBusiness(CRUDPlus[GenBusiness]): class CRUDCodeGenBusiness(CRUDPlus[CodeGenBusiness]):
"""代码生成业务 CRUD 类""" """代码生成业务 CRUD 类"""
async def get(self, db: AsyncSession, pk: int) -> GenBusiness | None: async def get(self, db: AsyncSession, pk: int) -> CodeGenBusiness | None:
""" """
获取代码生成业务 获取代码生成业务
@@ -22,7 +22,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
""" """
return await self.select_model(db, pk, deleted=0) return await self.select_model(db, pk, deleted=0)
async def get_by_name(self, db: AsyncSession, name: str) -> GenBusiness | None: async def get_by_name(self, db: AsyncSession, name: str) -> CodeGenBusiness | None:
""" """
通过 name 获取代码生成业务 通过 name 获取代码生成业务
@@ -32,7 +32,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
""" """
return await self.select_model_by_column(db, table_name=name, deleted=0) return await self.select_model_by_column(db, table_name=name, deleted=0)
async def get_all(self, db: AsyncSession) -> Sequence[GenBusiness]: async def get_all(self, db: AsyncSession) -> Sequence[CodeGenBusiness]:
""" """
获取所有代码生成业务 获取所有代码生成业务
@@ -55,7 +55,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
return await self.select_order('id', 'desc', **filters) return await self.select_order('id', 'desc', **filters)
async def create(self, db: AsyncSession, obj: CreateGenBusinessParam) -> None: async def create(self, db: AsyncSession, obj: CreateCodeGenBusinessParam) -> None:
""" """
创建代码生成业务 创建代码生成业务
@@ -65,7 +65,7 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
""" """
await self.create_model(db, obj) await self.create_model(db, obj)
async def update(self, db: AsyncSession, pk: int, obj: UpdateGenBusinessParam) -> int: async def update(self, db: AsyncSession, pk: int, obj: UpdateCodeGenBusinessParam) -> int:
""" """
更新代码生成业务 更新代码生成业务
@@ -96,4 +96,4 @@ class CRUDGenBusiness(CRUDPlus[GenBusiness]):
) )
gen_business_dao: CRUDGenBusiness = CRUDGenBusiness(GenBusiness) code_gen_business_dao: CRUDCodeGenBusiness = CRUDCodeGenBusiness(CodeGenBusiness)
@@ -7,7 +7,7 @@ from backend.common.enums import DataBaseType
from backend.core.conf import settings from backend.core.conf import settings
class CRUDGen: class CRUDCodeGen:
"""代码生成 CRUD 类""" """代码生成 CRUD 类"""
@staticmethod @staticmethod
@@ -194,4 +194,4 @@ class CRUDGen:
return result.mappings().all() return result.mappings().all()
gen_dao: CRUDGen = CRUDGen() code_gen_dao: CRUDCodeGen = CRUDCodeGen()
@@ -3,18 +3,18 @@ from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.plugin.code_generator.model import GenColumn from backend.plugin.code_generator.model import CodeGenColumn
from backend.plugin.code_generator.schema.column import ( from backend.plugin.code_generator.schema.column import (
CreateGenColumnInternalParam, CreateCodeGenColumnInternalParam,
CreateGenColumnParam, CreateCodeGenColumnParam,
UpdateGenColumnParam, UpdateCodeGenColumnParam,
) )
class CRUDGenColumn(CRUDPlus[GenColumn]): class CRUDCodeGenColumn(CRUDPlus[CodeGenColumn]):
"""代码生成模型列 CRUD 类""" """代码生成模型列 CRUD 类"""
async def get(self, db: AsyncSession, pk: int) -> GenColumn | None: async def get(self, db: AsyncSession, pk: int) -> CodeGenColumn | None:
""" """
获取代码生成模型列 获取代码生成模型列
@@ -24,7 +24,7 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
""" """
return await self.select_model(db, pk) return await self.select_model(db, pk)
async def get_all_by_business(self, db: AsyncSession, business_id: int) -> Sequence[GenColumn]: async def get_all_by_business(self, db: AsyncSession, business_id: int) -> Sequence[CodeGenColumn]:
""" """
获取所有代码生成模型列 获取所有代码生成模型列
@@ -32,9 +32,9 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
:param business_id: 业务 ID :param business_id: 业务 ID
:return: :return:
""" """
return await self.select_models_order(db, sort_columns='sort', gen_business_id=business_id) return await self.select_models_order(db, sort_columns='sort', code_gen_business_id=business_id)
async def create(self, db: AsyncSession, obj: CreateGenColumnParam, pd_type: str | None) -> None: async def create(self, db: AsyncSession, obj: CreateCodeGenColumnParam, pd_type: str | None) -> None:
""" """
创建代码生成模型列 创建代码生成模型列
@@ -45,7 +45,7 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
""" """
await self.create_model(db, obj, pd_type=pd_type) await self.create_model(db, obj, pd_type=pd_type)
async def bulk_create(self, db: AsyncSession, objs: list[CreateGenColumnInternalParam]) -> None: async def bulk_create(self, db: AsyncSession, objs: list[CreateCodeGenColumnInternalParam]) -> None:
""" """
批量创建代码生成模型列 批量创建代码生成模型列
@@ -55,7 +55,7 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
""" """
await self.create_models(db, objs) await self.create_models(db, objs)
async def update(self, db: AsyncSession, pk: int, obj: UpdateGenColumnParam, pd_type: str | None) -> int: async def update(self, db: AsyncSession, pk: int, obj: UpdateCodeGenColumnParam, pd_type: str | None) -> int:
""" """
更新代码生成模型列 更新代码生成模型列
@@ -78,4 +78,4 @@ class CRUDGenColumn(CRUDPlus[GenColumn]):
return await self.delete_model(db, pk) return await self.delete_model(db, pk)
gen_column_dao: CRUDGenColumn = CRUDGenColumn(GenColumn) code_gen_column_dao: CRUDCodeGenColumn = CRUDCodeGenColumn(CodeGenColumn)
@@ -1,2 +1,2 @@
from backend.plugin.code_generator.model.business import GenBusiness as GenBusiness from backend.plugin.code_generator.model.business import CodeGenBusiness as CodeGenBusiness
from backend.plugin.code_generator.model.column import GenColumn as GenColumn from backend.plugin.code_generator.model.column import CodeGenColumn as CodeGenColumn
@@ -5,12 +5,12 @@ from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import Base, UniversalText, id_key from backend.common.model import Base, UniversalText, id_key
class GenBusiness(Base): class CodeGenBusiness(Base):
"""代码生成业务表""" """代码生成业务表"""
__tablename__ = 'gen_business' __tablename__ = 'code_gen_business'
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint('table_name', 'deleted', name='uk_gen_business_table_name_deleted'), sa.UniqueConstraint('table_name', 'deleted', name='uk_code_gen_business_table_name_deleted'),
{'comment': '代码生成业务表'}, {'comment': '代码生成业务表'},
) )
@@ -5,12 +5,12 @@ from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, UniversalText, id_key from backend.common.model import DataClassBase, UniversalText, id_key
class GenColumn(DataClassBase): class CodeGenColumn(DataClassBase):
"""代码生成模型列表""" """代码生成模型列表"""
__tablename__ = 'gen_column' __tablename__ = 'code_gen_column'
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint('gen_business_id', 'name', name='uk_gen_column_business_id_name'), sa.UniqueConstraint('code_gen_business_id', 'name', name='uk_code_gen_column_business_id_name'),
{'comment': '代码生成模型列表'}, {'comment': '代码生成模型列表'},
) )
@@ -26,4 +26,4 @@ class GenColumn(DataClassBase):
is_nullable: Mapped[bool] = mapped_column(default=False, comment='是否可为空') is_nullable: Mapped[bool] = mapped_column(default=False, comment='是否可为空')
# 逻辑外键 # 逻辑外键
gen_business_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='代码生成业务ID') code_gen_business_id: Mapped[int] = mapped_column(sa.BigInteger, default=0, comment='代码生成业务ID')
@@ -7,7 +7,7 @@ from backend.common.schema import SchemaBase
from backend.utils.pattern_validate import is_english_identifier from backend.utils.pattern_validate import is_english_identifier
class GenBusinessSchemaBase(SchemaBase): class CodeGenBusinessSchemaBase(SchemaBase):
"""代码生成业务基础模型""" """代码生成业务基础模型"""
app_name: str = Field(description='应用名称(英文)') app_name: str = Field(description='应用名称(英文)')
@@ -32,15 +32,15 @@ class GenBusinessSchemaBase(SchemaBase):
return v return v
class CreateGenBusinessParam(GenBusinessSchemaBase): class CreateCodeGenBusinessParam(CodeGenBusinessSchemaBase):
"""创建代码生成业务参数""" """创建代码生成业务参数"""
class UpdateGenBusinessParam(GenBusinessSchemaBase): class UpdateCodeGenBusinessParam(CodeGenBusinessSchemaBase):
"""更新代码生成业务参数""" """更新代码生成业务参数"""
class GetGenBusinessDetail(GenBusinessSchemaBase): class GetCodeGenBusinessDetail(CodeGenBusinessSchemaBase):
"""获取代码生成业务详情""" """获取代码生成业务详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -4,7 +4,7 @@ from backend.common.schema import SchemaBase
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_sqlalchemy from backend.plugin.code_generator.utils.type_conversion import sql_type_to_sqlalchemy
class GenColumnSchemaBase(SchemaBase): class CodeGenColumnSchemaBase(SchemaBase):
"""代码生成模型基础模型""" """代码生成模型基础模型"""
name: str = Field(description='列名称') name: str = Field(description='列名称')
@@ -15,7 +15,7 @@ class GenColumnSchemaBase(SchemaBase):
length: int = Field(description='列长度') length: int = Field(description='列长度')
is_pk: bool = Field(False, description='是否主键') is_pk: bool = Field(False, description='是否主键')
is_nullable: bool = Field(False, description='是否可为空') is_nullable: bool = Field(False, description='是否可为空')
gen_business_id: int = Field(description='代码生成业务ID') code_gen_business_id: int = Field(description='代码生成业务ID')
@field_validator('type') @field_validator('type')
@classmethod @classmethod
@@ -24,21 +24,21 @@ class GenColumnSchemaBase(SchemaBase):
return sql_type_to_sqlalchemy(v) return sql_type_to_sqlalchemy(v)
class CreateGenColumnParam(GenColumnSchemaBase): class CreateCodeGenColumnParam(CodeGenColumnSchemaBase):
"""创建代码生成模型列参数""" """创建代码生成模型列参数"""
class CreateGenColumnInternalParam(CreateGenColumnParam): class CreateCodeGenColumnInternalParam(CreateCodeGenColumnParam):
"""创建代码生成模型列内部参数""" """创建代码生成模型列内部参数"""
pd_type: str | None = Field(None, description='列类型对应的 pydantic 类型') pd_type: str | None = Field(None, description='列类型对应的 pydantic 类型')
class UpdateGenColumnParam(GenColumnSchemaBase): class UpdateCodeGenColumnParam(CodeGenColumnSchemaBase):
"""更新代码生成模型列参数""" """更新代码生成模型列参数"""
class GetGenColumnDetail(GenColumnSchemaBase): class GetCodeGenColumnDetail(CodeGenColumnSchemaBase):
"""获取代码生成模型列详情""" """获取代码生成模型列详情"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -5,16 +5,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.pagination import paging_data from backend.common.pagination import paging_data
from backend.plugin.code_generator.crud.crud_business import gen_business_dao from backend.plugin.code_generator.crud.crud_business import code_gen_business_dao
from backend.plugin.code_generator.model import GenBusiness from backend.plugin.code_generator.model import CodeGenBusiness
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam, UpdateGenBusinessParam from backend.plugin.code_generator.schema.business import CreateCodeGenBusinessParam, UpdateCodeGenBusinessParam
class GenBusinessService: class CodeGenBusinessService:
"""代码生成业务服务类""" """代码生成业务服务类"""
@staticmethod @staticmethod
async def get(*, db: AsyncSession, pk: int) -> GenBusiness: async def get(*, db: AsyncSession, pk: int) -> CodeGenBusiness:
""" """
获取指定 ID 的业务 获取指定 ID 的业务
@@ -23,13 +23,13 @@ class GenBusinessService:
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
return business return business
@staticmethod @staticmethod
async def get_all(*, db: AsyncSession) -> Sequence[GenBusiness]: async def get_all(*, db: AsyncSession) -> Sequence[CodeGenBusiness]:
""" """
获取所有业务 获取所有业务
@@ -37,7 +37,7 @@ class GenBusinessService:
:return: :return:
""" """
return await gen_business_dao.get_all(db) return await code_gen_business_dao.get_all(db)
@staticmethod @staticmethod
async def get_list(*, db: AsyncSession, table_name: str) -> dict[str, Any]: async def get_list(*, db: AsyncSession, table_name: str) -> dict[str, Any]:
@@ -48,11 +48,11 @@ class GenBusinessService:
:param table_name: 业务表名 :param table_name: 业务表名
:return: :return:
""" """
business_select = await gen_business_dao.get_select(table_name=table_name) business_select = await code_gen_business_dao.get_select(table_name=table_name)
return await paging_data(db, business_select) return await paging_data(db, business_select)
@staticmethod @staticmethod
async def create(*, db: AsyncSession, obj: CreateGenBusinessParam) -> None: async def create(*, db: AsyncSession, obj: CreateCodeGenBusinessParam) -> None:
""" """
创建业务 创建业务
@@ -61,13 +61,13 @@ class GenBusinessService:
:return: :return:
""" """
business = await gen_business_dao.get_by_name(db, obj.table_name) business = await code_gen_business_dao.get_by_name(db, obj.table_name)
if business: if business:
raise errors.ConflictError(msg='代码生成业务已存在') raise errors.ConflictError(msg='代码生成业务已存在')
await gen_business_dao.create(db, obj) await code_gen_business_dao.create(db, obj)
@staticmethod @staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateGenBusinessParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateCodeGenBusinessParam) -> int:
""" """
更新业务 更新业务
@@ -77,12 +77,12 @@ class GenBusinessService:
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
if business.table_name != obj.table_name and await gen_business_dao.get_by_name(db, obj.table_name): if business.table_name != obj.table_name and await code_gen_business_dao.get_by_name(db, obj.table_name):
raise errors.ConflictError(msg='代码生成业务已存在') raise errors.ConflictError(msg='代码生成业务已存在')
return await gen_business_dao.update(db, pk, obj) return await code_gen_business_dao.update(db, pk, obj)
@staticmethod @staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int: async def delete(*, db: AsyncSession, pk: int) -> int:
@@ -94,10 +94,10 @@ class GenBusinessService:
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
return await gen_business_dao.delete(db, pk) return await code_gen_business_dao.delete(db, pk)
gen_business_service: GenBusinessService = GenBusinessService() code_gen_business_service: CodeGenBusinessService = CodeGenBusinessService()
@@ -17,21 +17,21 @@ from starlette.concurrency import run_in_threadpool
from backend.common.exception import errors from backend.common.exception import errors
from backend.core.conf import settings from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH from backend.core.path_conf import BASE_PATH
from backend.plugin.code_generator.crud.crud_business import gen_business_dao from backend.plugin.code_generator.crud.crud_business import code_gen_business_dao
from backend.plugin.code_generator.crud.crud_column import gen_column_dao from backend.plugin.code_generator.crud.crud_code_gen import code_gen_dao
from backend.plugin.code_generator.crud.crud_gen import gen_dao from backend.plugin.code_generator.crud.crud_column import code_gen_column_dao
from backend.plugin.code_generator.model import GenBusiness from backend.plugin.code_generator.model import CodeGenBusiness
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam from backend.plugin.code_generator.schema.business import CreateCodeGenBusinessParam
from backend.plugin.code_generator.schema.column import CreateGenColumnInternalParam from backend.plugin.code_generator.schema.code_gen import ImportParam
from backend.plugin.code_generator.schema.gen import ImportParam from backend.plugin.code_generator.schema.column import CreateCodeGenColumnInternalParam
from backend.plugin.code_generator.service.column_service import gen_column_service from backend.plugin.code_generator.service.column_service import code_gen_column_service
from backend.plugin.code_generator.utils.format_code import format_python_code from backend.plugin.code_generator.utils.format_code import format_python_code
from backend.plugin.code_generator.utils.gen_template import gen_template from backend.plugin.code_generator.utils.gen_template import gen_template
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
from backend.utils.locks import acquire_distributed_reload_lock from backend.utils.locks import acquire_distributed_reload_lock
class GenService: class CodeGenService:
"""代码生成服务类""" """代码生成服务类"""
@staticmethod @staticmethod
@@ -43,7 +43,7 @@ class GenService:
:param table_schema: 数据库 schema 名称 :param table_schema: 数据库 schema 名称
:return: :return:
""" """
return await gen_dao.get_all_tables(db, table_schema) return await code_gen_dao.get_all_tables(db, table_schema)
@staticmethod @staticmethod
async def import_business_and_model(*, db: AsyncSession, obj: ImportParam) -> None: async def import_business_and_model(*, db: AsyncSession, obj: ImportParam) -> None:
@@ -57,11 +57,11 @@ class GenService:
if settings.ENVIRONMENT != 'dev': if settings.ENVIRONMENT != 'dev':
raise errors.ForbiddenError(msg='禁止在非开发环境下导入代码生成业务') raise errors.ForbiddenError(msg='禁止在非开发环境下导入代码生成业务')
table_info = await gen_dao.get_table(db, obj.table_schema, obj.table_name) table_info = await code_gen_dao.get_table(db, obj.table_schema, obj.table_name)
if not table_info: if not table_info:
raise errors.NotFoundError(msg='数据库表不存在') raise errors.NotFoundError(msg='数据库表不存在')
business_info = await gen_business_dao.get_by_name(db, obj.table_name) business_info = await code_gen_business_dao.get_by_name(db, obj.table_name)
if business_info: if business_info:
raise errors.ConflictError(msg='已存在相同数据库表业务') raise errors.ConflictError(msg='已存在相同数据库表业务')
@@ -71,8 +71,8 @@ class GenService:
if table_info['table_comment'][-1] == '' if table_info['table_comment'][-1] == ''
else table_info['table_comment'] or table_name.split('_')[-1] else table_info['table_comment'] or table_name.split('_')[-1]
) )
new_business = GenBusiness( new_business = CodeGenBusiness(
**CreateGenBusinessParam( **CreateCodeGenBusinessParam(
app_name=obj.app, app_name=obj.app,
table_name=table_name, table_name=table_name,
doc_comment=doc_comment, doc_comment=doc_comment,
@@ -86,13 +86,13 @@ class GenService:
db.add(new_business) db.add(new_business)
await db.flush() await db.flush()
column_info = await gen_dao.get_all_columns(db, obj.table_schema, table_name) column_info = await code_gen_dao.get_all_columns(db, obj.table_schema, table_name)
gen_columns = [] code_gen_columns = []
for column in column_info: for column in column_info:
column_type = column['column_type'].split('(')[0].upper() column_type = column['column_type'].split('(')[0].upper()
pd_type = sql_type_to_pydantic(column_type) pd_type = sql_type_to_pydantic(column_type)
gen_columns.append( code_gen_columns.append(
CreateGenColumnInternalParam( CreateCodeGenColumnInternalParam(
name=column['column_name'], name=column['column_name'],
comment=column['column_comment'], comment=column['column_comment'],
type=column_type, type=column_type,
@@ -102,14 +102,14 @@ class GenService:
else 0, else 0,
is_pk=column['is_pk'], is_pk=column['is_pk'],
is_nullable=column['is_nullable'], is_nullable=column['is_nullable'],
gen_business_id=new_business.id, code_gen_business_id=new_business.id,
pd_type=pd_type, pd_type=pd_type,
), ),
) )
await gen_column_dao.bulk_create(db, gen_columns) await code_gen_column_dao.bulk_create(db, code_gen_columns)
@staticmethod @staticmethod
async def _render_tpl_code(*, db: AsyncSession, business: GenBusiness) -> dict[str, str]: async def _render_tpl_code(*, db: AsyncSession, business: CodeGenBusiness) -> dict[str, str]:
""" """
渲染模板代码 渲染模板代码
@@ -117,7 +117,7 @@ class GenService:
:param business: 业务对象 :param business: 业务对象
:return: :return:
""" """
gen_models = await gen_column_service.get_columns(db=db, business_id=business.id) gen_models = await code_gen_column_service.get_columns(db=db, business_id=business.id)
if not gen_models: if not gen_models:
raise errors.NotFoundError(msg='代码生成模型表为空') raise errors.NotFoundError(msg='代码生成模型表为空')
@@ -176,7 +176,7 @@ class GenService:
:param pk: 业务 ID :param pk: 业务 ID
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='业务不存在') raise errors.NotFoundError(msg='业务不存在')
@@ -206,7 +206,7 @@ class GenService:
:param pk: 业务 ID :param pk: 业务 ID
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='业务不存在') raise errors.NotFoundError(msg='业务不存在')
@@ -232,7 +232,7 @@ class GenService:
if settings.ENVIRONMENT != 'dev': if settings.ENVIRONMENT != 'dev':
raise errors.ForbiddenError(msg='禁止在非开发环境下生成代码') raise errors.ForbiddenError(msg='禁止在非开发环境下生成代码')
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='业务不存在') raise errors.NotFoundError(msg='业务不存在')
@@ -274,7 +274,7 @@ class GenService:
:param pk: 业务 ID :param pk: 业务 ID
:return: :return:
""" """
business = await gen_business_dao.get(db, pk) business = await code_gen_business_dao.get(db, pk)
if not business: if not business:
raise errors.NotFoundError(msg='业务不存在') raise errors.NotFoundError(msg='业务不存在')
@@ -297,4 +297,4 @@ class GenService:
return bio return bio
gen_service: GenService = GenService() code_gen_service: CodeGenService = CodeGenService()
@@ -5,19 +5,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.enums import DataBaseType from backend.common.enums import DataBaseType
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.plugin.code_generator.crud.crud_business import gen_business_dao from backend.plugin.code_generator.crud.crud_business import code_gen_business_dao
from backend.plugin.code_generator.crud.crud_column import gen_column_dao from backend.plugin.code_generator.crud.crud_column import code_gen_column_dao
from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType from backend.plugin.code_generator.enums import GenMySQLColumnType, GenPostgreSQLColumnType
from backend.plugin.code_generator.model import GenColumn from backend.plugin.code_generator.model import CodeGenColumn
from backend.plugin.code_generator.schema.column import CreateGenColumnParam, UpdateGenColumnParam from backend.plugin.code_generator.schema.column import CreateCodeGenColumnParam, UpdateCodeGenColumnParam
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
class GenColumnService: class CodeGenColumnService:
"""代码生成模型列服务类""" """代码生成模型列服务类"""
@staticmethod @staticmethod
async def get(*, db: AsyncSession, pk: int) -> GenColumn: async def get(*, db: AsyncSession, pk: int) -> CodeGenColumn:
""" """
获取指定 ID 的模型列 获取指定 ID 的模型列
@@ -26,10 +26,10 @@ class GenColumnService:
:return: :return:
""" """
column = await gen_column_dao.get(db, pk) column = await code_gen_column_dao.get(db, pk)
if not column: if not column:
raise errors.NotFoundError(msg='代码生成模型列不存在') raise errors.NotFoundError(msg='代码生成模型列不存在')
if not await gen_business_dao.get(db, column.gen_business_id): if not await code_gen_business_dao.get(db, column.code_gen_business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
return column return column
@@ -44,7 +44,7 @@ class GenColumnService:
return types return types
@staticmethod @staticmethod
async def get_columns(*, db: AsyncSession, business_id: int) -> Sequence[GenColumn]: async def get_columns(*, db: AsyncSession, business_id: int) -> Sequence[CodeGenColumn]:
""" """
获取指定业务的所有模型列 获取指定业务的所有模型列
@@ -53,12 +53,12 @@ class GenColumnService:
:return: :return:
""" """
if not await gen_business_dao.get(db, business_id): if not await code_gen_business_dao.get(db, business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
return await gen_column_dao.get_all_by_business(db, business_id) return await code_gen_column_dao.get_all_by_business(db, business_id)
@staticmethod @staticmethod
async def create(*, db: AsyncSession, obj: CreateGenColumnParam) -> None: async def create(*, db: AsyncSession, obj: CreateCodeGenColumnParam) -> None:
""" """
创建模型列 创建模型列
@@ -67,18 +67,18 @@ class GenColumnService:
:return: :return:
""" """
if not await gen_business_dao.get(db, obj.gen_business_id): if not await code_gen_business_dao.get(db, obj.code_gen_business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) code_gen_columns = await code_gen_column_dao.get_all_by_business(db, obj.code_gen_business_id)
if obj.name in [gen_column.name for gen_column in gen_columns]: if obj.name in [code_gen_column.name for code_gen_column in code_gen_columns]:
raise errors.ForbiddenError(msg='模型列已存在') raise errors.ForbiddenError(msg='模型列已存在')
pd_type = sql_type_to_pydantic(obj.type) pd_type = sql_type_to_pydantic(obj.type)
await gen_column_dao.create(db, obj, pd_type=pd_type) await code_gen_column_dao.create(db, obj, pd_type=pd_type)
@staticmethod @staticmethod
async def update(*, db: AsyncSession, pk: int, obj: UpdateGenColumnParam) -> int: async def update(*, db: AsyncSession, pk: int, obj: UpdateCodeGenColumnParam) -> int:
""" """
更新模型列 更新模型列
@@ -88,20 +88,20 @@ class GenColumnService:
:return: :return:
""" """
column = await gen_column_dao.get(db, pk) column = await code_gen_column_dao.get(db, pk)
if not column: if not column:
raise errors.NotFoundError(msg='代码生成模型列不存在') raise errors.NotFoundError(msg='代码生成模型列不存在')
if not await gen_business_dao.get(db, column.gen_business_id): if not await code_gen_business_dao.get(db, column.code_gen_business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
if not await gen_business_dao.get(db, obj.gen_business_id): if not await code_gen_business_dao.get(db, obj.code_gen_business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
if obj.name != column.name: if obj.name != column.name:
gen_columns = await gen_column_dao.get_all_by_business(db, obj.gen_business_id) code_gen_columns = await code_gen_column_dao.get_all_by_business(db, obj.code_gen_business_id)
if obj.name in [gen_column.name for gen_column in gen_columns]: if obj.name in [code_gen_column.name for code_gen_column in code_gen_columns]:
raise errors.ConflictError(msg='模型列名已存在') raise errors.ConflictError(msg='模型列名已存在')
pd_type = sql_type_to_pydantic(obj.type) pd_type = sql_type_to_pydantic(obj.type)
return await gen_column_dao.update(db, pk, obj, pd_type=pd_type) return await code_gen_column_dao.update(db, pk, obj, pd_type=pd_type)
@staticmethod @staticmethod
async def delete(*, db: AsyncSession, pk: int) -> int: async def delete(*, db: AsyncSession, pk: int) -> int:
@@ -113,12 +113,12 @@ class GenColumnService:
:return: :return:
""" """
column = await gen_column_dao.get(db, pk) column = await code_gen_column_dao.get(db, pk)
if not column: if not column:
raise errors.NotFoundError(msg='代码生成模型列不存在') raise errors.NotFoundError(msg='代码生成模型列不存在')
if not await gen_business_dao.get(db, column.gen_business_id): if not await code_gen_business_dao.get(db, column.code_gen_business_id):
raise errors.NotFoundError(msg='代码生成业务不存在') raise errors.NotFoundError(msg='代码生成业务不存在')
return await gen_column_dao.delete(db, pk) return await code_gen_column_dao.delete(db, pk)
gen_column_service: GenColumnService = GenColumnService() code_gen_column_service: CodeGenColumnService = CodeGenColumnService()
@@ -2,5 +2,5 @@ delete from sys_menu where name in ('AddGenCodeBusiness', 'EditGenCodeBusiness',
delete from sys_menu where name = 'PluginCodeGenerator'; delete from sys_menu where name = 'PluginCodeGenerator';
drop table if exists gen_column; drop table if exists code_gen_column;
drop table if exists gen_business; drop table if exists code_gen_business;
@@ -2,5 +2,5 @@ delete from sys_menu where name in ('AddGenCodeBusiness', 'EditGenCodeBusiness',
delete from sys_menu where name = 'PluginCodeGenerator'; delete from sys_menu where name = 'PluginCodeGenerator';
drop table if exists gen_column; drop table if exists code_gen_column;
drop table if exists gen_business; drop table if exists code_gen_business;
@@ -1,5 +1,5 @@
insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values ('code_generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code_generator/views/index', null, 1, 1, 1, '', null, null, now(), null); values ('code-generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code-generator/views/index', null, 1, 1, 1, '', null, null, now(), null);
set @codegen_menu_id = LAST_INSERT_ID(); set @codegen_menu_id = LAST_INSERT_ID();
@@ -14,10 +14,10 @@ values
('导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, @codegen_menu_id, now(), null), ('导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, @codegen_menu_id, now(), null),
('写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, @codegen_menu_id, now(), null); ('写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, @codegen_menu_id, now(), null);
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time) insert into code_gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time)
values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null); values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, gen_business_id) insert into code_gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, code_gen_business_id)
values values
(1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1), (1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1),
(2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1), (2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1),
@@ -1,5 +1,5 @@
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values (2049629108257816580, 'code_generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code_generator/views/index', null, 1, 1, 1, '', null, null, now(), null); values (2049629108257816580, 'code-generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code-generator/views/index', null, 1, 1, 1, '', null, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values values
@@ -12,10 +12,10 @@ values
(2049629108257816587, '导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, 2049629108257816580, now(), null), (2049629108257816587, '导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, 2049629108257816580, now(), null),
(2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, now(), null); (2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, now(), null);
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time) insert into code_gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time)
values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null); values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, gen_business_id) insert into code_gen_column (id, name, comment, type, pd_type, `default`, sort, `length`, is_pk, is_nullable, code_gen_business_id)
values values
(2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840), (2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840),
(2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840), (2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840),
@@ -2,7 +2,7 @@ delete from sys_menu where name in ('AddGenCodeBusiness', 'EditGenCodeBusiness',
delete from sys_menu where name = 'PluginCodeGenerator'; delete from sys_menu where name = 'PluginCodeGenerator';
drop table if exists gen_column; drop table if exists code_gen_column;
drop table if exists gen_business; drop table if exists code_gen_business;
select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu; select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu;
@@ -2,5 +2,5 @@ delete from sys_menu where name in ('AddGenCodeBusiness', 'EditGenCodeBusiness',
delete from sys_menu where name = 'PluginCodeGenerator'; delete from sys_menu where name = 'PluginCodeGenerator';
drop table if exists gen_column; drop table if exists code_gen_column;
drop table if exists gen_business; drop table if exists code_gen_business;
@@ -3,7 +3,7 @@ declare
codegen_menu_id bigint; codegen_menu_id bigint;
begin begin
insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values ('code_generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code_generator/views/index', null, 1, 1, 1, '', null, null, now(), null) values ('code-generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code-generator/views/index', null, 1, 1, 1, '', null, null, now(), null)
returning id into codegen_menu_id; returning id into codegen_menu_id;
insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
@@ -20,10 +20,10 @@ end $$;
select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu; select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu;
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time) insert into code_gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time)
values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null); values (1, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, gen_business_id) insert into code_gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, code_gen_business_id)
values values
(1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1), (1, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 1),
(2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1), (2, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 1),
@@ -45,5 +45,5 @@ values
(18, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 1), (18, 'cost_time', '请求耗时(ms', 'String', 'str', null, 19, 0, false, false, 1),
(19, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 1); (19, 'opera_time', '操作时间', 'String', 'str', null, 20, 0, false, false, 1);
select setval(pg_get_serial_sequence('gen_business', 'id'),coalesce(max(id), 0) + 1, true) from gen_business; select setval(pg_get_serial_sequence('code_gen_business', 'id'),coalesce(max(id), 0) + 1, true) from code_gen_business;
select setval(pg_get_serial_sequence('gen_column', 'id'),coalesce(max(id), 0) + 1, true) from gen_column; select setval(pg_get_serial_sequence('code_gen_column', 'id'),coalesce(max(id), 0) + 1, true) from code_gen_column;
@@ -1,5 +1,5 @@
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values (2049629108257816580, 'code_generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code_generator/views/index', null, 1, 1, 1, '', null, null, now(), null); values (2049629108257816580, 'code-generator.menu', 'PluginCodeGenerator', '/plugins/code-generator', 10, 'tabler:code', 1, '/plugins/code-generator/views/index', null, 1, 1, 1, '', null, null, now(), null);
insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) insert into sys_menu (id, title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time)
values values
@@ -12,10 +12,10 @@ values
(2049629108257816587, '导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, 2049629108257816580, now(), null), (2049629108257816587, '导入', 'ImportGenCode', null, 0, null, 2, null, 'codegen:table:import', 1, 0, 1, '', null, 2049629108257816580, now(), null),
(2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, now(), null); (2049629108257816588, '写入', 'WriteGenCode', null, 0, null, 2, null, 'codegen:local:write', 1, 0, 1, '', null, 2049629108257816580, now(), null);
insert into gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time) insert into code_gen_business (id, app_name, table_name, doc_comment, table_comment, class_name, schema_name, filename, datetime_mixin, api_version, gen_path, remark, created_time, updated_time)
values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null); values (2112248797819043840, 'test', 'sys_opera_log', '操作日志表', '操作日志表', 'SysOperaLog', 'SysOperaLog', 'sys_opera_log', true, 'v1', null, null, '2025-12-15 15:30:33', null);
insert into gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, gen_business_id) insert into code_gen_column (id, name, comment, type, pd_type, "default", sort, "length", is_pk, is_nullable, code_gen_business_id)
values values
(2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840), (2112248797881958400, 'trace_id', '请求跟踪 ID', 'String', 'str', null, 2, 32, false, false, 2112248797819043840),
(2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840), (2112248797944872960, 'username', '用户名', 'String', 'str', null, 3, 64, false, true, 2112248797819043840),
@@ -49,8 +49,8 @@ class Get{{ schema_name }}Detail({{ schema_name }}SchemaBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int = Field(description='主键 ID')
{% if datetime_mixin %} {% if datetime_mixin %}
created_time: datetime created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = None updated_time: datetime | None = Field(None, description='更新时间')
{% endif %} {% endif %}
@@ -10,6 +10,8 @@ from backend.common.pagination import paging_data
class {{ class_name }}Service: class {{ class_name }}Service:
"""{{ doc_comment }}服务类"""
@staticmethod @staticmethod
async def get(*, db: AsyncSession, pk: int) -> {{ class_name }}: async def get(*, db: AsyncSession, pk: int) -> {{ class_name }}:
""" """
@@ -25,7 +27,7 @@ class {{ class_name }}Service:
return {{ table_name }} return {{ table_name }}
@staticmethod @staticmethod
async def get_list(db: AsyncSession) -> dict[str, Any]: async def get_list(*, db: AsyncSession) -> dict[str, Any]:
""" """
获取{{ doc_comment }}列表 获取{{ doc_comment }}列表
@@ -5,7 +5,7 @@ from pydantic.alias_generators import to_pascal
from backend.common.enums import PrimaryKeyType from backend.common.enums import PrimaryKeyType
from backend.core.conf import settings from backend.core.conf import settings
from backend.plugin.code_generator.model import GenBusiness, GenColumn from backend.plugin.code_generator.model import CodeGenBusiness, CodeGenColumn
from backend.plugin.code_generator.path_conf import JINJA2_TEMPLATE_DIR from backend.plugin.code_generator.path_conf import JINJA2_TEMPLATE_DIR
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_sqlalchemy_name from backend.plugin.code_generator.utils.type_conversion import sql_type_to_sqlalchemy_name
from backend.utils.snowflake import snowflake from backend.utils.snowflake import snowflake
@@ -31,17 +31,17 @@ class GenTemplate:
获取 Jinja2 模板对象 获取 Jinja2 模板对象
:param jinja_file: Jinja2 模板文件路径 :param jinja_file: Jinja2 模板文件路径
:return: Template 对象 :return:
""" """
return self.env.get_template(jinja_file) return self.env.get_template(jinja_file)
@staticmethod @staticmethod
def get_template_path_mapping(business: GenBusiness) -> dict[str, str]: def get_template_path_mapping(business: CodeGenBusiness) -> dict[str, str]:
""" """
获取模板文件到生成文件的路径映射 获取模板文件到生成文件的路径映射
:param business: 代码生成业务对象 :param business: 代码生成业务对象
:return: {模板路径: 生成文件路径} :return:
""" """
app_name = business.app_name app_name = business.app_name
filename = business.filename filename = business.filename
@@ -59,12 +59,12 @@ class GenTemplate:
f'sql/postgresql/init{pk_suffix}.jinja': f'{app_name}/sql/postgresql/init{pk_suffix}.sql', f'sql/postgresql/init{pk_suffix}.jinja': f'{app_name}/sql/postgresql/init{pk_suffix}.sql',
} }
def get_init_files(self, business: GenBusiness) -> dict[str, str]: def get_init_files(self, business: CodeGenBusiness) -> dict[str, str]:
""" """
获取需要生成的 __init__.py 文件及其内容 获取需要生成的 __init__.py 文件及其内容
:param business: 业务对象 :param business: 业务对象
:return: {相对路径: 文件内容} :return:
""" """
app_name = business.app_name app_name = business.app_name
table_name = business.table_name table_name = business.table_name
@@ -84,7 +84,9 @@ class GenTemplate:
} }
@staticmethod @staticmethod
def get_vars(business: GenBusiness, models: Sequence[GenColumn]) -> dict[str, str | Sequence[GenColumn]]: def get_vars(
business: CodeGenBusiness, models: Sequence[CodeGenColumn]
) -> dict[str, str | Sequence[CodeGenColumn]]:
""" """
获取模板变量 获取模板变量
+9 -1
View File
@@ -31,7 +31,7 @@ EMAIL_PORT = 465
EMAIL_SSL = true EMAIL_SSL = true
``` ```
`backend/core/conf.py` 中添加以下内容 当前项目的 `backend/core/conf.py` 已包含以下字段
```python ```python
################################################## ##################################################
@@ -49,6 +49,14 @@ EMAIL_CAPTCHA_REDIS_PREFIX: str
EMAIL_CAPTCHA_EXPIRE_SECONDS: int EMAIL_CAPTCHA_EXPIRE_SECONDS: int
``` ```
## 配置项说明
- `EMAIL_CAPTCHA_EXPIRE_SECONDS`:控制邮箱验证码有效期
- `EMAIL_CAPTCHA_REDIS_PREFIX`:控制邮箱验证码 Redis 键前缀
- `EMAIL_HOST`:控制 SMTP 服务器地址
- `EMAIL_PORT`:控制 SMTP 端口
- `EMAIL_SSL`:控制是否启用 SSL
## 使用方式 ## 使用方式
1. 安装并启用插件后,配置正确的 SMTP 账号与密码 1. 安装并启用插件后,配置正确的 SMTP 账号与密码
+2 -1
View File
@@ -183,7 +183,8 @@ async def install_git_frontend_plugin(repo_url: str, frontend_project_root: str)
raise errors.RequestError(msg='未检测到前端插件目录,请确认路径下存在 apps/web-antdv-next/src/plugins') raise errors.RequestError(msg='未检测到前端插件目录,请确认路径下存在 apps/web-antdv-next/src/plugins')
repo_name = match.group('repo') repo_name = match.group('repo')
plugin_name = repo_name.removesuffix('_ui') # 仓库名允许 _ui 或 -ui 后缀,安装目录不保留该后缀
plugin_name = repo_name.removesuffix('_ui') if repo_name.endswith('_ui') else repo_name.removesuffix('-ui')
if not plugin_name: if not plugin_name:
raise errors.RequestError(msg='前端插件仓库名称非法') raise errors.RequestError(msg='前端插件仓库名称非法')
+10 -1
View File
@@ -34,7 +34,7 @@ OAUTH2_STATE_EXPIRE_SECONDS = 180
OAUTH2_STATE_REDIS_PREFIX = 'fba:oauth2:state' OAUTH2_STATE_REDIS_PREFIX = 'fba:oauth2:state'
``` ```
`backend/core/conf.py` 中添加以下内容 当前项目的 `backend/core/conf.py` 已包含以下字段
```python ```python
################################################## ##################################################
@@ -55,6 +55,15 @@ OAUTH2_FRONTEND_LOGIN_REDIRECT_URI: str
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str
``` ```
## 配置项说明
- `OAUTH2_FRONTEND_BINDING_REDIRECT_URI`:控制第三方账号绑定完成后的前端回跳地址
- `OAUTH2_FRONTEND_LOGIN_REDIRECT_URI`:控制第三方登录完成后的前端回跳地址
- `OAUTH2_GITHUB_REDIRECT_URI`:控制 GitHub OAuth 回调地址
- `OAUTH2_GOOGLE_REDIRECT_URI`:控制 Google OAuth 回调地址
- `OAUTH2_STATE_EXPIRE_SECONDS`:控制 OAuth state 有效期
- `OAUTH2_STATE_REDIS_PREFIX`:控制 OAuth state Redis 键前缀
## 使用方式 ## 使用方式
1. 安装并启用插件后,在 GitHub、Google 开放平台分别创建 OAuth 应用 1. 安装并启用插件后,在 GitHub、Google 开放平台分别创建 OAuth 应用
@@ -14,7 +14,7 @@ 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.i18n import t
from backend.common.security import jwt from backend.common.security.token import create_access_token, create_refresh_token
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
@@ -94,7 +94,7 @@ class OAuth2Service:
await user_social_dao.create(db, new_user_social) await user_social_dao.create(db, new_user_social)
# 创建 token # 创建 token
access_token_data = await jwt.create_access_token( access_token_data = await create_access_token(
sys_user.id, sys_user.id,
multi_login=sys_user.is_multi_login, multi_login=sys_user.is_multi_login,
# extra info # extra info
@@ -106,7 +106,7 @@ class OAuth2Service:
browser=ctx.browser, browser=ctx.browser,
device=ctx.device, device=ctx.device,
) )
refresh_token_data = await jwt.create_refresh_token( refresh_token_data = await create_refresh_token(
access_token_data.session_uuid, access_token_data.session_uuid,
sys_user.id, sys_user.id,
multi_login=sys_user.is_multi_login, multi_login=sys_user.is_multi_login,
@@ -12,6 +12,8 @@ from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
class UserSocialService: class UserSocialService:
"""用户社交账号服务类"""
@staticmethod @staticmethod
async def get_bindings(*, db: AsyncSession, user_id: int) -> list[str]: async def get_bindings(*, db: AsyncSession, user_id: int) -> list[str]:
""" """
@@ -19,7 +21,7 @@ class UserSocialService:
:param db: 数据库会话 :param db: 数据库会话
:param user_id: 用户 ID :param user_id: 用户 ID
:return: 绑定列表每个元素包含 sidsource 等信息 :return:
""" """
bindings = await user_social_dao.get_by_user_id(db, user_id) bindings = await user_social_dao.get_by_user_id(db, user_id)
return [binding.source for binding in bindings] return [binding.source for binding in bindings]
+4 -1
View File
@@ -67,7 +67,10 @@ class PluginStatusChecker:
:param request: FastAPI 请求对象 :param request: FastAPI 请求对象
:return: :return:
""" """
plugin_info = cast('str | None', await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{self.plugin}')) plugin_info = cast(
'str | None',
await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{self.plugin}'),
)
if not plugin_info: if not plugin_info:
log.warning('插件 {} 状态未初始化或丢失,尝试自动修复', self.plugin) log.warning('插件 {} 状态未初始化或丢失,尝试自动修复', self.plugin)
try: try:
+1 -1
View File
@@ -37,7 +37,7 @@ def _validate_settings(v: dict[str, Any]) -> dict[str, Any]:
class PluginInfoSchema(BaseModel): class PluginInfoSchema(BaseModel):
"""插件信息模型""" """插件信息模型"""
icon: str | None = Field(default=None, description='图标路径或链接地址') icon: str | None = Field(None, description='图标路径或链接地址')
summary: str = Field(..., min_length=1, max_length=100, description='摘要') summary: str = Field(..., min_length=1, max_length=100, description='摘要')
version: str = Field(..., description='版本号') version: str = Field(..., description='版本号')
description: str = Field(..., min_length=1, max_length=500, description='描述') description: str = Field(..., min_length=1, max_length=500, description='描述')
+40 -26
View File
@@ -110,7 +110,8 @@ class RedisBucketFactory(BucketFactory):
:return: :return:
""" """
bucket_key = self._bucket_key(item.name) bucket_key = self._bucket_key(item.name)
now = await _redis_time_ms(redis_client) # wrap_item 已取过 Redis 时间,直接复用,省一次往返
now = item.timestamp
async with self.lock: async with self.lock:
state = self.buckets.get(bucket_key) state = self.buckets.get(bucket_key)
@@ -119,15 +120,28 @@ class RedisBucketFactory(BucketFactory):
self.buckets.move_to_end(bucket_key) self.buckets.move_to_end(bucket_key)
return state.bucket return state.bucket
bucket_result = RedisTimeBucket.init( # 锁外做 Redis IO,避免所有限流请求排队等待 script_load
bucket = await _maybe_await(
RedisTimeBucket.init(
rates=self.rates, rates=self.rates,
redis=redis_client, redis=redis_client,
bucket_key=bucket_key, bucket_key=bucket_key,
) )
bucket = await _maybe_await(bucket_result) )
async with self.lock:
# 并发初始化同一 bucket 时以先写入者为准
state = self.buckets.get(bucket_key)
if state is not None:
state.last_seen = now
self.buckets.move_to_end(bucket_key)
return state.bucket
self.buckets[bucket_key] = RedisBucketState(bucket=bucket, last_seen=now) self.buckets[bucket_key] = RedisBucketState(bucket=bucket, last_seen=now)
self.schedule_leak(bucket) self.schedule_leak(bucket)
await self._evict(now) disposed = self._evict(now)
for state in disposed:
await self._cleanup(state.bucket, now)
return bucket return bucket
async def get_bucket(self, name: str) -> RedisBucket: async def get_bucket(self, name: str) -> RedisBucket:
@@ -139,38 +153,38 @@ class RedisBucketFactory(BucketFactory):
""" """
return await self.get(await self.wrap_item(name)) return await self.get(await self.wrap_item(name))
async def _evict(self, now: int) -> None: def _evict(self, now: int) -> list[RedisBucketState]:
""" """
淘汰本地 bucket 缓存 淘汰本地 bucket 缓存只改内存状态不做 Redis IO
:param now: 当前时间戳单位毫秒 :param now: 当前时间戳单位毫秒
:return: :return:
""" """
expired: list[RedisBucketState] = []
for bucket_key, state in list(self.buckets.items()): for bucket_key, state in list(self.buckets.items()):
if now - state.last_seen <= self.cache_ttl: if now - state.last_seen <= self.cache_ttl:
continue continue
await self._dispose(bucket_key, state, now, cleanup=True)
while len(self.buckets) > self.max_cache_size:
bucket_key, state = next(iter(self.buckets.items()))
await self._dispose(bucket_key, state, now, cleanup=False)
async def _dispose(self, bucket_key: str, state: RedisBucketState, now: int, *, cleanup: bool) -> None:
"""
移除本地 bucket 并按需清理 Redis 过期数据
:param bucket_key: Redis bucket key
:param state: Redis bucket 缓存状态
:param now: 当前时间戳单位毫秒
:param cleanup: 是否执行 Redis 过期数据清理
:return:
"""
self.buckets.pop(bucket_key, None) self.buckets.pop(bucket_key, None)
self.dispose(state.bucket) self.dispose(state.bucket)
if cleanup: expired.append(state)
await _maybe_await(state.bucket.leak(now))
if await _maybe_await(state.bucket.count()) == 0: while len(self.buckets) > self.max_cache_size:
await _maybe_await(state.bucket.flush()) _, state = self.buckets.popitem(last=False)
self.dispose(state.bucket)
return expired
@staticmethod
async def _cleanup(bucket: RedisBucket, now: int) -> None:
"""
清理已淘汰 bucket Redis 过期数据
:param bucket: Redis bucket
:param now: 当前时间戳单位毫秒
:return:
"""
await _maybe_await(bucket.leak(now))
if await _maybe_await(bucket.count()) == 0:
await _maybe_await(bucket.flush())
def _bucket_key(self, name: str) -> str: def _bucket_key(self, name: str) -> str:
""" """
+3 -1
View File
@@ -4,6 +4,7 @@ from typing import Any
import anyio import anyio
from backend.common.exception import errors
from backend.core.path_conf import RELOAD_LOCK_FILE from backend.core.path_conf import RELOAD_LOCK_FILE
from backend.database.redis import redis_client from backend.database.redis import redis_client
@@ -16,7 +17,8 @@ async def acquire_distributed_reload_lock() -> AsyncGenerator[None, Any]:
timeout=300, # 锁持有超时:5 分钟 timeout=300, # 锁持有超时:5 分钟
blocking_timeout=60, # 获取锁等待超时:60 秒 blocking_timeout=60, # 获取锁等待超时:60 秒
) )
await lock.acquire() if not await lock.acquire():
raise errors.ServerError(msg='获取热重载锁超时,请稍后重试')
# 文件锁(通知文件监控器跳过重载) # 文件锁(通知文件监控器跳过重载)
lock_path = anyio.Path(RELOAD_LOCK_FILE) lock_path = anyio.Path(RELOAD_LOCK_FILE)
+7 -2
View File
@@ -53,7 +53,7 @@ class SnowflakeNodeManager:
async def acquire_node_id(self) -> tuple[int, int]: async def acquire_node_id(self) -> tuple[int, int]:
"""从 Redis 获取可用的 datacenter_id 和 worker_id""" """从 Redis 获取可用的 datacenter_id 和 worker_id"""
occupied_nodes = set() occupied_nodes = set()
async for key in redis_client.scan_iter(match=f'{self.node_redis_prefix}:*'): async for key in redis_client.scan_iter(match=f'{self.node_redis_prefix}:*', count=1000):
parts = key.split(':') parts = key.split(':')
if len(parts) >= 5: if len(parts) >= 5:
try: try:
@@ -118,6 +118,7 @@ class Snowflake:
self.last_timestamp: int = -1 self.last_timestamp: int = -1
self._lock = threading.Lock() self._lock = threading.Lock()
self._init_lock = asyncio.Lock()
self._initialized = False self._initialized = False
self._node_manager: SnowflakeNodeManager | None = None self._node_manager: SnowflakeNodeManager | None = None
self._auto_allocated = False # 标记是否由 Redis 自动分配 ID self._auto_allocated = False # 标记是否由 Redis 自动分配 ID
@@ -127,7 +128,11 @@ class Snowflake:
if self._initialized: if self._initialized:
return return
with self._lock: # 初始化涉及 Redis IO,必须用 asyncio.Lockthreading.Lock 会在 await 期间锁死事件循环
async with self._init_lock:
if self._initialized:
return
# 环境变量固定分配 # 环境变量固定分配
if settings.SNOWFLAKE_DATACENTER_ID is not None and settings.SNOWFLAKE_WORKER_ID is not None: if settings.SNOWFLAKE_DATACENTER_ID is not None and settings.SNOWFLAKE_WORKER_ID is not None:
self.datacenter_id = settings.SNOWFLAKE_DATACENTER_ID self.datacenter_id = settings.SNOWFLAKE_DATACENTER_ID