From 5e06a019a806ba2012113282a71faba6c66af5c3 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 8 Mar 2026 21:52:54 +0800 Subject: [PATCH 1/8] Add tenant plugin --- __init__.py | 0 api/__init__.py | 0 api/router.py | 10 ++ api/v1/__init__.py | 0 api/v1/package.py | 101 +++++++++++ api/v1/tenant.py | 128 ++++++++++++++ crud/__init__.py | 0 crud/crud_package.py | 165 ++++++++++++++++++ crud/crud_tenant.py | 153 +++++++++++++++++ filter.py | 14 ++ listener.py | 28 +++ model/__init__.py | 2 + model/m2m.py | 12 ++ model/package.py | 17 ++ model/tenant.py | 26 +++ plugin.toml | 14 ++ schema/__init__.py | 0 schema/package.py | 43 +++++ schema/tenant.py | 64 +++++++ service/__init__.py | 0 service/package_service.py | 112 ++++++++++++ service/tenant_service.py | 246 +++++++++++++++++++++++++++ sql/mysql/destroy.sql | 13 ++ sql/mysql/destroy_snowflake.sql | 13 ++ sql/mysql/init.sql | 30 ++++ sql/mysql/init_snowflake.sql | 21 +++ sql/postgresql/destroy.sql | 15 ++ sql/postgresql/destroy_snowflake.sql | 13 ++ sql/postgresql/init.sql | 33 ++++ sql/postgresql/init_snowflake.sql | 21 +++ 30 files changed, 1294 insertions(+) create mode 100644 __init__.py create mode 100644 api/__init__.py create mode 100644 api/router.py create mode 100644 api/v1/__init__.py create mode 100644 api/v1/package.py create mode 100644 api/v1/tenant.py create mode 100644 crud/__init__.py create mode 100644 crud/crud_package.py create mode 100644 crud/crud_tenant.py create mode 100644 filter.py create mode 100644 listener.py create mode 100644 model/__init__.py create mode 100644 model/m2m.py create mode 100644 model/package.py create mode 100644 model/tenant.py create mode 100644 plugin.toml create mode 100644 schema/__init__.py create mode 100644 schema/package.py create mode 100644 schema/tenant.py create mode 100644 service/__init__.py create mode 100644 service/package_service.py create mode 100644 service/tenant_service.py create mode 100644 sql/mysql/destroy.sql create mode 100644 sql/mysql/destroy_snowflake.sql create mode 100644 sql/mysql/init.sql create mode 100644 sql/mysql/init_snowflake.sql create mode 100644 sql/postgresql/destroy.sql create mode 100644 sql/postgresql/destroy_snowflake.sql create mode 100644 sql/postgresql/init.sql create mode 100644 sql/postgresql/init_snowflake.sql diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/router.py b/api/router.py new file mode 100644 index 0000000..f616760 --- /dev/null +++ b/api/router.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from backend.core.conf import settings +from backend.plugin.tenant.api.v1.package import router as package_router +from backend.plugin.tenant.api.v1.tenant import router as tenant_router + +v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) + +v1.include_router(tenant_router, prefix='/tenants', tags=['租户管理']) +v1.include_router(package_router, prefix='/tenant/packages', tags=['租户套餐管理']) diff --git a/api/v1/__init__.py b/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/v1/package.py b/api/v1/package.py new file mode 100644 index 0000000..f61ac57 --- /dev/null +++ b/api/v1/package.py @@ -0,0 +1,101 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from backend.app.admin.schema.menu import GetMenuTree +from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.jwt import DependsJwtAuth +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.database.db import CurrentSession, CurrentSessionTransaction +from backend.plugin.tenant.crud.crud_package import tenant_package_dao +from backend.plugin.tenant.schema.package import ( + CreateTenantPackageParam, + GetTenantPackageDetail, + UpdateTenantPackageParam, +) +from backend.plugin.tenant.service.package_service import tenant_package_service + +router = APIRouter() + + +@router.get('/{pk}', summary='获取套餐详情', dependencies=[DependsJwtAuth]) +async def get_tenant_package( + db: CurrentSession, pk: Annotated[int, Path(description='套餐 ID')] +) -> ResponseSchemaModel[GetTenantPackageDetail]: + package = await tenant_package_service.get(db=db, pk=pk) + return response_base.success(data=package) + + +@router.get('/{pk}/menus', summary='获取套餐菜单树', dependencies=[DependsJwtAuth]) +async def get_tenant_package_menus( + db: CurrentSession, pk: Annotated[int, Path(description='套餐 ID')] +) -> ResponseSchemaModel[list[GetMenuTree] | None]: + menus = await tenant_package_service.get_menu_tree(db=db, pk=pk) + return response_base.success(data=menus) + + +@router.get( + '', + summary='分页获取所有套餐', + dependencies=[ + DependsJwtAuth, + DependsPagination, + ], +) +async def get_tenant_packages_paginated( + db: CurrentSession, + name: Annotated[str | None, Query(description='套餐名称')] = None, + status: Annotated[int | None, Query(description='状态')] = None, +) -> ResponseSchemaModel[PageData[GetTenantPackageDetail]]: + package_select = await tenant_package_dao.get_select(name=name, status=status) + page_data = await paging_data(db, package_select) + return response_base.success(data=page_data) + + +@router.post( + '', + summary='创建套餐', + dependencies=[ + Depends(RequestPermission('tenant:package:add')), + DependsRBAC, + ], +) +async def create_tenant_package(db: CurrentSessionTransaction, obj: CreateTenantPackageParam) -> ResponseModel: + await tenant_package_service.create(db=db, obj=obj) + return response_base.success() + + +@router.put( + '/{pk}', + summary='更新套餐', + dependencies=[ + Depends(RequestPermission('tenant:package:edit')), + DependsRBAC, + ], +) +async def update_tenant_package( + db: CurrentSessionTransaction, pk: Annotated[int, Path(description='套餐 ID')], obj: UpdateTenantPackageParam +) -> ResponseModel: + count = await tenant_package_service.update(db=db, pk=pk, obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.delete( + '/{pk}', + summary='删除套餐', + dependencies=[ + Depends(RequestPermission('tenant:package:del')), + DependsRBAC, + ], +) +async def delete_tenant_package( + db: CurrentSessionTransaction, pk: Annotated[int, Path(description='套餐 ID')] +) -> ResponseModel: + count = await tenant_package_service.delete(db=db, pk=pk) + if count > 0: + return response_base.success() + return response_base.fail() diff --git a/api/v1/tenant.py b/api/v1/tenant.py new file mode 100644 index 0000000..ad4c26d --- /dev/null +++ b/api/v1/tenant.py @@ -0,0 +1,128 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.jwt import DependsJwtAuth +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.core.conf import settings +from backend.database.db import CurrentSession, CurrentSessionTransaction +from backend.plugin.tenant.schema.tenant import ( + CreateTenantParam, + DeleteTenantParam, + GetTenantDetail, + UpdateTenantAdminPwdParam, + UpdateTenantParam, +) +from backend.plugin.tenant.service.tenant_service import tenant_service + +router = APIRouter() + + +@router.get('/enabled', summary='获取租户开启状态') +async def get_tenant_enabled_status() -> ResponseSchemaModel[bool]: + return response_base.success(data=settings.TENANT_ENABLED) + + +@router.get('/id', summary='获取租户 ID') +async def get_tenant_id( + db: CurrentSession, + domain: Annotated[str, Query(description='租户域名')], +) -> ResponseSchemaModel[int | None]: + tenant_id = await tenant_service.get_id_by_domain(db=db, domain=domain) + return response_base.success(data=tenant_id) + + +@router.get('/{pk}', summary='获取租户详情', dependencies=[DependsJwtAuth]) +async def get_tenant( + db: CurrentSession, pk: Annotated[int, Path(description='租户 ID')] +) -> ResponseSchemaModel[GetTenantDetail]: + tenant = await tenant_service.get(db=db, pk=pk) + return response_base.success(data=tenant) + + +@router.get( + '', + summary='分页获取所有租户', + dependencies=[ + DependsJwtAuth, + DependsPagination, + ], +) +async def get_tenants_paginated( + db: CurrentSession, + name: Annotated[str | None, Query(description='租户名称')] = None, + code: Annotated[str | None, Query(description='租户编码')] = None, + domain: Annotated[str | None, Query(description='租户域名')] = None, + package_id: Annotated[int | None, Query(description='套餐 ID')] = None, + status: Annotated[int | None, Query(description='状态')] = None, +) -> ResponseSchemaModel[PageData[GetTenantDetail]]: + tenant_select = await tenant_service.get_select( + name=name, code=code, domain=domain, package_id=package_id, status=status + ) + page_data = await paging_data(db, tenant_select) + return response_base.success(data=page_data) + + +@router.post( + '', + summary='创建租户', + dependencies=[ + Depends(RequestPermission('tenant:management:add')), + DependsRBAC, + ], +) +async def create_tenant(db: CurrentSessionTransaction, obj: CreateTenantParam) -> ResponseModel: + await tenant_service.create(db=db, obj=obj) + return response_base.success() + + +@router.put( + '/{pk}', + summary='更新租户', + dependencies=[ + Depends(RequestPermission('tenant:management:edit')), + DependsRBAC, + ], +) +async def update_tenant( + db: CurrentSessionTransaction, pk: Annotated[int, Path(description='租户 ID')], obj: UpdateTenantParam +) -> ResponseModel: + count = await tenant_service.update(db=db, pk=pk, obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.put( + '/{pk}/admin/password', + summary='修改租户管理员密码', + dependencies=[ + Depends(RequestPermission('tenant:management:pwd')), + DependsRBAC, + ], +) +async def update_tenant_admin_password( + db: CurrentSessionTransaction, + pk: Annotated[int, Path(description='租户 ID')], + obj: UpdateTenantAdminPwdParam, +) -> ResponseModel: + await tenant_service.update_admin_password(db=db, pk=pk, password=obj.password) + return response_base.success() + + +@router.delete( + '', + summary='批量删除租户', + dependencies=[ + Depends(RequestPermission('tenant:management:del')), + DependsRBAC, + ], +) +async def delete_tenants(db: CurrentSessionTransaction, obj: DeleteTenantParam) -> ResponseModel: + count = await tenant_service.delete(db=db, obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() diff --git a/crud/__init__.py b/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crud/crud_package.py b/crud/crud_package.py new file mode 100644 index 0000000..76d4cef --- /dev/null +++ b/crud/crud_package.py @@ -0,0 +1,165 @@ +from collections.abc import Sequence + +from sqlalchemy import Select, delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.admin.model import Menu +from backend.plugin.tenant.model import TenantPackage +from backend.plugin.tenant.model.m2m import package_menu +from backend.plugin.tenant.schema.package import CreateTenantPackageParam, UpdateTenantPackageParam + + +class CRUDTenantPackage(CRUDPlus[TenantPackage]): + async def get(self, db: AsyncSession, pk: int) -> TenantPackage | None: + """ + 获取套餐 + + :param db: 数据库会话 + :param pk: 套餐 ID + :return: + """ + return await self.select_model(db, pk) + + async def get_by_name(self, db: AsyncSession, name: str) -> TenantPackage | None: + """ + 通过名称获取套餐 + + :param db: 数据库会话 + :param name: 套餐名称 + :return: + """ + return await self.select_model_by_column(db, name=name) + + async def get_select( + self, + *, + name: str | None = None, + status: int | None = None, + ) -> Select: + """ + 获取套餐列表查询表达式 + + :param name: 套餐名称 + :param status: 状态 + :return: + """ + filters = {} + + if name is not None: + filters.update(name__like=f'%{name}%') + if status is not None: + filters.update(status=status) + + return await self.select_order('sort', 'asc', **filters) + + async def get_all(self, db: AsyncSession) -> Sequence[TenantPackage]: + """ + 获取所有套餐 + + :param db: 数据库会话 + :return: + """ + return await self.select_models(db) + + async def create(self, db: AsyncSession, obj: CreateTenantPackageParam) -> TenantPackage: + """ + 创建套餐 + + :param db: 数据库会话 + :param obj: 创建套餐参数 + :return: + """ + dict_obj = obj.model_dump(exclude={'menus'}) + new_package = await self.create_model(db, dict_obj) + await db.flush() + return new_package + + async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantPackageParam) -> int: + """ + 更新套餐 + + :param db: 数据库会话 + :param pk: 套餐 ID + :param obj: 更新套餐参数 + :return: + """ + dict_obj = obj.model_dump(exclude={'menus'}, exclude_none=True) + return await self.update_model(db, pk, dict_obj) + + async def delete(self, db: AsyncSession, pk: int) -> int: + """ + 删除套餐 + + :param db: 数据库会话 + :param pk: 套餐 ID + :return: + """ + return await self.delete_model(db, pk) + + @staticmethod + async def get_menu_ids(db: AsyncSession, package_id: int) -> list[int]: + """ + 获取套餐关联的菜单 ID 列表 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + stmt = select(package_menu.c.menu_id).where(package_menu.c.package_id == package_id) + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + @staticmethod + async def get_menus(db: AsyncSession, package_id: int) -> Sequence[Menu] | None: + """ + 获取套餐菜单 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + stmt = ( + select(Menu) + .join(package_menu, Menu.id == package_menu.c.menu_id) + .where(package_menu.c.package_id == package_id) + ) + result = await db.execute(stmt) + return result.scalars().all() + + @staticmethod + async def update_menus(db: AsyncSession, package_id: int, menu_ids: list[int]) -> None: + """ + 更新套餐菜单关联 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :param menu_ids: 菜单 ID 列表 + :return: + """ + # 删除旧关联 + del_stmt = delete(package_menu).where(package_menu.c.package_id == package_id) + await db.execute(del_stmt) + + # 添加新关联 + if menu_ids: + ins_stmt = insert(package_menu) + await db.execute( + ins_stmt, + [{'package_id': package_id, 'menu_id': menu_id} for menu_id in menu_ids], + ) + + @staticmethod + async def delete_menus_by_package_id(db: AsyncSession, package_id: int) -> None: + """ + 通过套餐 ID 删除菜单关联 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + del_stmt = delete(package_menu).where(package_menu.c.package_id == package_id) + await db.execute(del_stmt) + + +tenant_package_dao: CRUDTenantPackage = CRUDTenantPackage(TenantPackage) diff --git a/crud/crud_tenant.py b/crud/crud_tenant.py new file mode 100644 index 0000000..e8a93b3 --- /dev/null +++ b/crud/crud_tenant.py @@ -0,0 +1,153 @@ +from collections.abc import Sequence + +from sqlalchemy import Select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.plugin.tenant.model import Tenant +from backend.plugin.tenant.schema.tenant import CreateTenantParam, UpdateTenantParam + + +class CRUDTenant(CRUDPlus[Tenant]): + async def get(self, db: AsyncSession, pk: int) -> Tenant | None: + """ + 获取租户 + + :param db: 数据库会话 + :param pk: 租户 ID + :return: + """ + return await self.select_model(db, pk) + + async def get_by_name(self, db: AsyncSession, name: str) -> Tenant | None: + """ + 通过名称获取租户 + + :param db: 数据库会话 + :param name: 租户名称 + :return: + """ + return await self.select_model_by_column(db, name=name) + + async def get_by_code(self, db: AsyncSession, code: str) -> Tenant | None: + """ + 通过编码获取租户 + + :param db: 数据库会话 + :param code: 租户编码 + :return: + """ + return await self.select_model_by_column(db, code=code) + + async def get_by_domain(self, db: AsyncSession, domain: str) -> Tenant | None: + """ + 通过域名获取租户 + + :param db: 数据库会话 + :param domain: 租户域名 + :return: + """ + return await self.select_model_by_column(db, domain=domain) + + async def get_select( + self, + *, + name: str | None = None, + code: str | None = None, + domain: str | None = None, + package_id: int | None = None, + status: int | None = None, + ) -> Select: + """ + 获取租户列表查询表达式 + + :param name: 租户名称 + :param code: 租户编码 + :param domain: 租户域名 + :param package_id: 套餐 ID + :param status: 状态 + :return: + """ + filters = {} + + if name is not None: + filters.update(name__like=f'%{name}%') + if code is not None: + filters.update(code=code) + if domain is not None: + filters.update(domain__like=f'%{domain}%') + if package_id is not None: + filters.update(package_id=package_id) + if status is not None: + filters.update(status=status) + + return await self.select_order('id', 'desc', **filters) + + async def get_all(self, db: AsyncSession) -> Sequence[Tenant]: + """ + 获取所有租户 + + :param db: 数据库会话 + :return: + """ + return await self.select_models(db) + + async def create(self, db: AsyncSession, obj: CreateTenantParam, code: str) -> Tenant: + """ + 创建租户 + + :param db: 数据库会话 + :param obj: 创建租户参数 + :param code: 租户编码 + :return: + """ + dict_obj = obj.model_dump(exclude={'admin_username', 'admin_password'}) + dict_obj.update({'code': code, 'admin_username': obj.admin_username}) + new_tenant = await self.create_model(db, dict_obj) + await db.flush() + return new_tenant + + async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: + """ + 更新租户 + + :param db: 数据库会话 + :param pk: 租户 ID + :param obj: 更新租户参数 + :return: + """ + return await self.update_model(db, pk, obj) + + async def delete(self, db: AsyncSession, pks: list[int]) -> int: + """ + 批量删除租户 + + :param db: 数据库会话 + :param pks: 租户 ID 列表 + :return: + """ + return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks) + + async def get_by_package_id(self, db: AsyncSession, package_id: int) -> Tenant | None: + """ + 通过套餐 ID 获取租户 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + return await self.select_model_by_column(db, package_id=package_id) + + async def get_ids_by_package_id(self, db: AsyncSession, package_id: int) -> list[int]: + """ + 通过套餐 ID 获取租户 ID 列表 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + tenants = await self.select_models(db, package_id=package_id) + return [t.id for t in tenants] + + +tenant_dao: CRUDTenant = CRUDTenant(Tenant) diff --git a/filter.py b/filter.py new file mode 100644 index 0000000..687c147 --- /dev/null +++ b/filter.py @@ -0,0 +1,14 @@ +from backend.common.context import ctx + + +def get_tenant_dict(obj: dict) -> dict: + """ + 向数据字典中注入 tenant_id + + :param obj: 数据字典 + :return: + """ + tenant_id = ctx.tenant_id + if tenant_id is not None and 'tenant_id' not in obj: + obj['tenant_id'] = tenant_id + return obj diff --git a/listener.py b/listener.py new file mode 100644 index 0000000..6d240f9 --- /dev/null +++ b/listener.py @@ -0,0 +1,28 @@ +from sqlalchemy import event +from sqlalchemy.orm import ORMExecuteState, Session + +from backend.common.context import ctx +from backend.common.model import TenantMixin + + +def register_tenant_sqlalchemy_listeners() -> None: + """注册租户相关的 SQLAlchemy 事件监听器""" + + @event.listens_for(Session, 'do_orm_execute', propagate=True) + def _inject_tenant_filter(orm_execute_state: ORMExecuteState) -> None: + if not orm_execute_state.is_select: + return + tenant_id = ctx.tenant_id + if tenant_id is None: + return + mapper = orm_execute_state.bind_mapper + if mapper and hasattr(mapper.entity, 'tenant_id'): + orm_execute_state.statement = orm_execute_state.statement.where(mapper.entity.tenant_id == tenant_id) + + @event.listens_for(TenantMixin, 'before_insert', propagate=True) + def _inject_tenant_id(mapper, connection, target) -> None: # noqa: ANN001 + tenant_id = ctx.tenant_id + if tenant_id is None: + return + if hasattr(target, 'tenant_id') and target.tenant_id is None: + target.tenant_id = ctx.tenant_id diff --git a/model/__init__.py b/model/__init__.py new file mode 100644 index 0000000..fbc9d70 --- /dev/null +++ b/model/__init__.py @@ -0,0 +1,2 @@ +from backend.plugin.tenant.model.package import TenantPackage as TenantPackage +from backend.plugin.tenant.model.tenant import Tenant as Tenant diff --git a/model/m2m.py b/model/m2m.py new file mode 100644 index 0000000..2b6c919 --- /dev/null +++ b/model/m2m.py @@ -0,0 +1,12 @@ +import sqlalchemy as sa + +from backend.common.model import MappedBase + +# 套餐菜单关联表 +package_menu = sa.Table( + 'sys_tenant_package_menu', + MappedBase.metadata, + sa.Column('id', sa.BigInteger, primary_key=True, unique=True, index=True, autoincrement=True, comment='主键 ID'), + sa.Column('package_id', sa.BigInteger, nullable=False, index=True, comment='套餐 ID'), + sa.Column('menu_id', sa.BigInteger, nullable=False, index=True, comment='菜单 ID'), +) diff --git a/model/package.py b/model/package.py new file mode 100644 index 0000000..940409b --- /dev/null +++ b/model/package.py @@ -0,0 +1,17 @@ +import sqlalchemy as sa + +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import Base, UniversalText, id_key + + +class TenantPackage(Base): + """租户套餐表""" + + __tablename__ = 'sys_tenant_package' + + id: Mapped[id_key] = mapped_column(init=False) + name: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='套餐名称') + sort: Mapped[int] = mapped_column(default=999, comment='排序') + status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') + remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注') diff --git a/model/tenant.py b/model/tenant.py new file mode 100644 index 0000000..340c8aa --- /dev/null +++ b/model/tenant.py @@ -0,0 +1,26 @@ +from datetime import datetime + +import sqlalchemy as sa + +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import Base, TimeZone, UniversalText, id_key + + +class Tenant(Base): + """租户表""" + + __tablename__ = 'sys_tenant' + + id: Mapped[id_key] = mapped_column(init=False) + name: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='租户名称') + code: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='租户编码') + package_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='套餐 ID') + admin_user_id: Mapped[int | None] = mapped_column(sa.BigInteger, init=False, default=None, comment='管理员用户 ID') + admin_username: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='管理员用户名') + contact: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='联系人') + phone: Mapped[str | None] = mapped_column(sa.String(20), default=None, comment='手机号') + domain: Mapped[str | None] = mapped_column(sa.String(256), default=None, comment='租户域名') + expire_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='过期时间') + status: Mapped[int] = mapped_column(default=1, comment='状态(0停用 1正常)') + remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注') diff --git a/plugin.toml b/plugin.toml new file mode 100644 index 0000000..47e46b4 --- /dev/null +++ b/plugin.toml @@ -0,0 +1,14 @@ +[plugin] +summary = "多租户" +version = "0.0.1" +description = "为系统提供多租户能力,包括租户管理、套餐管理、行级数据隔离" +author = "wu-clan" +tags = ["other"] +database = ["mysql", "postgresql"] + +[app] +router = ["v1"] + +[settings] +TENANT_ADMIN_DEFAULT_ROLE_NAME = "租户管理员" +TENANT_DEFAULT_ID = 0 diff --git a/schema/__init__.py b/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schema/package.py b/schema/package.py new file mode 100644 index 0000000..a3c615f --- /dev/null +++ b/schema/package.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from pydantic import ConfigDict, Field + +from backend.common.enums import StatusType +from backend.common.schema import SchemaBase + + +class TenantPackageSchemaBase(SchemaBase): + """租户套餐基础模型""" + + name: str = Field(description='套餐名称') + sort: int = Field(default=999, description='排序') + status: StatusType = Field(default=StatusType.enable, description='状态') + remark: str | None = Field(None, description='备注') + + +class CreateTenantPackageParam(TenantPackageSchemaBase): + """创建租户套餐参数""" + + menus: list[int] = Field(description='菜单 ID 列表') + + +class UpdateTenantPackageParam(SchemaBase): + """更新租户套餐参数""" + + name: str | None = Field(None, description='套餐名称') + sort: int | None = Field(None, description='排序') + status: StatusType | None = Field(None, description='状态') + menus: list[int] | None = Field(None, description='菜单 ID 列表') + remark: str | None = Field(None, description='备注') + + +class GetTenantPackageDetail(TenantPackageSchemaBase): + """租户套餐详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='套餐 ID') + created_time: datetime = Field(description='创建时间') + updated_time: datetime | None = Field(None, description='更新时间') + + menu_ids: list[int] = Field(default=[], description='关联的菜单 ID 列表') diff --git a/schema/tenant.py b/schema/tenant.py new file mode 100644 index 0000000..437c786 --- /dev/null +++ b/schema/tenant.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from pydantic import ConfigDict, Field + +from backend.common.enums import StatusType +from backend.common.schema import SchemaBase + + +class TenantSchemaBase(SchemaBase): + """租户基础模型""" + + package_id: int = Field(description='套餐 ID') + name: str = Field(description='租户名称') + contact: str | None = Field(None, description='联系人') + phone: str | None = Field(None, description='手机号') + domain: str | None = Field(None, description='租户域名') + expire_time: datetime | None = Field(None, description='过期时间') + status: StatusType = Field(default=StatusType.enable, description='状态') + remark: str | None = Field(None, description='备注') + + +class CreateTenantParam(TenantSchemaBase): + """创建租户参数""" + + admin_username: str = Field(description='管理员用户名') + admin_password: str = Field(description='管理员密码') + + +class UpdateTenantParam(SchemaBase): + """更新租户参数""" + + package_id: int | None = Field(None, description='套餐 ID') + name: str | None = Field(None, description='租户名称') + contact: str | None = Field(None, description='联系人') + phone: str | None = Field(None, description='手机号') + domain: str | None = Field(None, description='租户域名') + expire_time: datetime | None = Field(None, description='过期时间') + status: StatusType | None = Field(None, description='状态') + remark: str | None = Field(None, description='备注') + + +class UpdateTenantAdminPwdParam(SchemaBase): + """修改租户管理员密码参数""" + + password: str = Field(description='新密码') + + +class DeleteTenantParam(SchemaBase): + """批量删除租户参数""" + + pks: list[int] = Field(description='租户 ID 列表') + + +class GetTenantDetail(TenantSchemaBase): + """租户详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='租户 ID') + code: str = Field(description='租户编码') + admin_user_id: int | None = Field(None, description='管理员用户 ID') + admin_username: str | None = Field(None, description='管理员用户名') + created_time: datetime = Field(description='创建时间') + updated_time: datetime | None = Field(None, description='更新时间') diff --git a/service/__init__.py b/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service/package_service.py b/service/package_service.py new file mode 100644 index 0000000..e67e69a --- /dev/null +++ b/service/package_service.py @@ -0,0 +1,112 @@ +from typing import Any + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model import Role +from backend.app.admin.model.m2m import role_menu +from backend.common.exception import errors +from backend.common.pagination import paging_data +from backend.core.conf import settings +from backend.plugin.tenant.crud.crud_package import tenant_package_dao +from backend.plugin.tenant.crud.crud_tenant import tenant_dao +from backend.plugin.tenant.model import TenantPackage +from backend.plugin.tenant.schema.package import ( + CreateTenantPackageParam, + GetTenantPackageDetail, + UpdateTenantPackageParam, +) +from backend.utils.build_tree import get_tree_data + + +class TenantPackageService: + @staticmethod + async def get(*, db: AsyncSession, pk: int) -> dict[str, Any]: + package = await tenant_package_dao.get(db, pk) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + menu_ids = await tenant_package_dao.get_menu_ids(db, pk) + data = GetTenantPackageDetail.model_validate(package).model_dump() + data['menu_ids'] = menu_ids + return data + + @staticmethod + async def get_menu_tree(*, db: AsyncSession, pk: int) -> list[dict[str, Any] | None]: + package = await tenant_package_dao.get(db, pk) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + menus = await tenant_package_dao.get_menus(db, pk) + return get_tree_data(menus) if menus else [] + + @staticmethod + async def get_list( + *, + db: AsyncSession, + name: str | None = None, + status: int | None = None, + ) -> dict[str, Any]: + package_select = await tenant_package_dao.get_select(name=name, status=status) + return await paging_data(db, package_select) + + @staticmethod + async def get_all(*, db: AsyncSession) -> list[TenantPackage]: + packages = await tenant_package_dao.get_all(db) + return list(packages) + + @staticmethod + async def create(*, db: AsyncSession, obj: CreateTenantPackageParam) -> None: + existing = await tenant_package_dao.get_by_name(db, obj.name) + if existing: + raise errors.ForbiddenError(msg='套餐名称已存在') + + package = await tenant_package_dao.create(db, obj) + await tenant_package_dao.update_menus(db, package.id, obj.menus) + + @staticmethod + async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantPackageParam) -> int: + package = await tenant_package_dao.get(db, pk) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + + if obj.name and obj.name != package.name: + existing = await tenant_package_dao.get_by_name(db, obj.name) + if existing: + raise errors.ForbiddenError(msg='套餐名称已存在') + + count = await tenant_package_dao.update(db, pk, obj) + + if obj.menus is not None: + await tenant_package_dao.update_menus(db, pk, obj.menus) + + tenant_ids = await tenant_dao.get_ids_by_package_id(db, pk) + for tenant_id in tenant_ids: + stmt = select(Role).where( + Role.tenant_id == tenant_id, Role.name == settings.TENANT_ADMIN_DEFAULT_ROLE_NAME + ) + admin_role = (await db.execute(stmt)).scalars().first() + if not admin_role: + continue + + await db.execute( + delete(role_menu).where(role_menu.c.role_id == admin_role.id, role_menu.c.tenant_id == tenant_id) + ) + + if obj.menus: + menu_data = [ + {'role_id': admin_role.id, 'menu_id': menu_id, 'tenant_id': tenant_id} for menu_id in obj.menus + ] + await db.execute(insert(role_menu), menu_data) + + return count + + @staticmethod + async def delete(*, db: AsyncSession, pk: int) -> int: + tenant = await tenant_dao.get_by_package_id(db, pk) + if tenant: + raise errors.ForbiddenError(msg='存在关联此套餐的租户,不允许删除') + + await tenant_package_dao.delete_menus_by_package_id(db, pk) + return await tenant_package_dao.delete(db, pk) + + +tenant_package_service: TenantPackageService = TenantPackageService() diff --git a/service/tenant_service.py b/service/tenant_service.py new file mode 100644 index 0000000..385a131 --- /dev/null +++ b/service/tenant_service.py @@ -0,0 +1,246 @@ +from typing import Any + +import bcrypt + +from fast_captcha import text_captcha +from sqlalchemy import Select, delete, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model import Dept, Role, User +from backend.app.admin.model.login_log import LoginLog +from backend.app.admin.model.m2m import role_data_scope, role_menu, user_role +from backend.app.admin.model.opera_log import OperaLog +from backend.app.admin.model.user_password_history import UserPasswordHistory +from backend.app.admin.utils.password_security import get_hash_password +from backend.common.exception import errors +from backend.common.pagination import paging_data +from backend.core.conf import settings +from backend.plugin.tenant.crud.crud_package import tenant_package_dao +from backend.plugin.tenant.crud.crud_tenant import tenant_dao +from backend.plugin.tenant.model import Tenant +from backend.plugin.tenant.schema.tenant import CreateTenantParam, DeleteTenantParam, UpdateTenantParam + + +class TenantService: + @staticmethod + async def get(*, db: AsyncSession, pk: int) -> Tenant: + tenant = await tenant_dao.get(db, pk) + if not tenant: + raise errors.NotFoundError(msg='租户不存在') + return tenant + + @staticmethod + async def get_by_code(*, db: AsyncSession, code: str) -> Tenant: + tenant = await tenant_dao.get_by_code(db, code) + if not tenant: + raise errors.NotFoundError(msg='租户不存在') + return tenant + + @staticmethod + async def get_by_domain(*, db: AsyncSession, domain: str) -> Tenant | None: + return await tenant_dao.get_by_domain(db, domain) + + @staticmethod + async def get_id_by_domain(*, db: AsyncSession, domain: str) -> int | None: + tenant = await tenant_dao.get_by_domain(db, domain) + return tenant.id if tenant else None + + @staticmethod + async def get_select( + *, + name: str | None = None, + code: str | None = None, + domain: str | None = None, + package_id: int | None = None, + status: int | None = None, + ) -> Select: + return await tenant_dao.get_select( + name=name, + code=code, + domain=domain, + package_id=package_id, + status=status, + ) + + @staticmethod + async def get_list( + *, + db: AsyncSession, + name: str | None = None, + code: str | None = None, + domain: str | None = None, + package_id: int | None = None, + status: int | None = None, + ) -> dict[str, Any]: + tenant_select = await tenant_dao.get_select( + name=name, + code=code, + domain=domain, + package_id=package_id, + status=status, + ) + return await paging_data(db, tenant_select) + + @staticmethod + async def create(*, db: AsyncSession, obj: CreateTenantParam) -> None: + existing = await tenant_dao.get_by_name(db, obj.name) + if existing: + raise errors.ForbiddenError(msg='租户名称已存在') + + if obj.domain: + existing_domain = await tenant_dao.get_by_domain(db, obj.domain) + if existing_domain: + raise errors.ForbiddenError(msg='租户域名已存在') + + package = await tenant_package_dao.get(db, obj.package_id) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + if package.status == 0: + raise errors.ForbiddenError(msg='租户套餐已被禁用') + + code = text_captcha(6) + while await tenant_dao.get_by_code(db, code): + code = text_captcha(6) + + tenant = await tenant_dao.create(db, obj, code) + tenant_id = tenant.id + + dept = Dept( + name=tenant.name, + sort=0, + status=1, + del_flag=False, + tenant_id=tenant_id, + ) + db.add(dept) + await db.flush() + + role = Role( + name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + status=1, + is_filter_scopes=False, + tenant_id=tenant_id, + ) + db.add(role) + await db.flush() + + menus = await tenant_package_dao.get_menus(db, tenant.package_id) + if menus: + menu_data = [{'role_id': role.id, 'menu_id': menu.id, 'tenant_id': tenant_id} for menu in menus] + await db.execute(insert(role_menu), menu_data) + + salt = bcrypt.gensalt() + hashed_password = get_hash_password(obj.admin_password, salt) + admin_user = User( + username=obj.admin_username, + nickname=f'{tenant.name}管理员', + password=hashed_password, + salt=salt, + status=1, + is_superuser=False, + is_staff=True, + is_multi_login=False, + dept_id=dept.id, + tenant_id=tenant_id, + ) + db.add(admin_user) + await db.flush() + + await db.execute( + insert(user_role), + [{'user_id': admin_user.id, 'role_id': role.id, 'tenant_id': tenant_id}], + ) + + await db.execute( + update(Tenant) + .where(Tenant.id == tenant_id) + .values(admin_user_id=admin_user.id, admin_username=obj.admin_username) + ) + + @staticmethod + async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: # noqa: C901 + tenant = await tenant_dao.get(db, pk) + if not tenant: + raise errors.NotFoundError(msg='租户不存在') + + if obj.name and obj.name != tenant.name: + existing = await tenant_dao.get_by_name(db, obj.name) + if existing: + raise errors.ForbiddenError(msg='租户名称已存在') + + if obj.domain and obj.domain != tenant.domain: + existing_domain = await tenant_dao.get_by_domain(db, obj.domain) + if existing_domain: + raise errors.ForbiddenError(msg='租户域名已存在') + + if obj.package_id and obj.package_id != tenant.package_id: + package = await tenant_package_dao.get(db, obj.package_id) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + if package.status == 0: + raise errors.ForbiddenError(msg='租户套餐已被禁用') + + stmt = select(Role).where(Role.tenant_id == pk, Role.name == settings.TENANT_ADMIN_DEFAULT_ROLE_NAME) + admin_role = (await db.execute(stmt)).scalars().first() + if admin_role: + await db.execute( + delete(role_menu).where(role_menu.c.role_id == admin_role.id, role_menu.c.tenant_id == pk) + ) + + menus = await tenant_package_dao.get_menus(db, obj.package_id) + if menus: + menu_data = [{'role_id': admin_role.id, 'menu_id': menu.id, 'tenant_id': pk} for menu in menus] + await db.execute(insert(role_menu), menu_data) + + return await tenant_dao.update(db, pk, obj) + + @staticmethod + async def update_admin_password(*, db: AsyncSession, pk: int, password: str) -> None: + tenant = await tenant_dao.get(db, pk) + if not tenant: + raise errors.NotFoundError(msg='租户不存在') + if not tenant.admin_user_id: + raise errors.NotFoundError(msg='租户管理员用户不存在') + + salt = bcrypt.gensalt() + hashed_password = get_hash_password(password, salt) + await db.execute( + update(User).where(User.id == tenant.admin_user_id).values(password=hashed_password, salt=salt) + ) + + @staticmethod + async def delete(*, db: AsyncSession, obj: DeleteTenantParam) -> int: + for pk in obj.pks: + tenant = await tenant_dao.get(db, pk) + if not tenant: + continue + + await db.execute(delete(user_role).where(user_role.c.tenant_id == pk)) + await db.execute(delete(role_menu).where(role_menu.c.tenant_id == pk)) + await db.execute(delete(role_data_scope).where(role_data_scope.c.tenant_id == pk)) + await db.execute(delete(UserPasswordHistory).where(UserPasswordHistory.tenant_id == pk)) + await db.execute(delete(OperaLog).where(OperaLog.tenant_id == pk)) + await db.execute(delete(LoginLog).where(LoginLog.tenant_id == pk)) + + try: + from backend.plugin.oauth2.model.user_social import UserSocial + + await db.execute(delete(UserSocial).where(UserSocial.tenant_id == pk)) + except ImportError: + pass + + try: + from backend.plugin.notice.model.notice import Notice + + await db.execute(delete(Notice).where(Notice.tenant_id == pk)) + except ImportError: + pass + + await db.execute(delete(User).where(User.tenant_id == pk)) + await db.execute(delete(Role).where(Role.tenant_id == pk)) + await db.execute(delete(Dept).where(Dept.tenant_id == pk)) + + return await tenant_dao.delete(db, obj.pks) + + +tenant_service: TenantService = TenantService() diff --git a/sql/mysql/destroy.sql b/sql/mysql/destroy.sql new file mode 100644 index 0000000..9de173c --- /dev/null +++ b/sql/mysql/destroy.sql @@ -0,0 +1,13 @@ +delete from sys_user_role where tenant_id > 0; +delete from sys_role_menu where tenant_id > 0; +delete from sys_user where tenant_id > 0; +delete from sys_role where tenant_id > 0; +delete from sys_dept where tenant_id > 0; + +delete from sys_menu where name in ('AddTenant', 'EditTenant', 'DeleteTenant', 'ResetTenantAdminPwd', 'AddTenantPackage', 'EditTenantPackage', 'DeleteTenantPackage'); +delete from sys_menu where name in ('TenantList', 'TenantPackage'); +delete from sys_menu where name = 'TenantManagement'; + +drop table if exists sys_tenant_package_menu; +drop table if exists sys_tenant; +drop table if exists sys_tenant_package; diff --git a/sql/mysql/destroy_snowflake.sql b/sql/mysql/destroy_snowflake.sql new file mode 100644 index 0000000..9de173c --- /dev/null +++ b/sql/mysql/destroy_snowflake.sql @@ -0,0 +1,13 @@ +delete from sys_user_role where tenant_id > 0; +delete from sys_role_menu where tenant_id > 0; +delete from sys_user where tenant_id > 0; +delete from sys_role where tenant_id > 0; +delete from sys_dept where tenant_id > 0; + +delete from sys_menu where name in ('AddTenant', 'EditTenant', 'DeleteTenant', 'ResetTenantAdminPwd', 'AddTenantPackage', 'EditTenantPackage', 'DeleteTenantPackage'); +delete from sys_menu where name in ('TenantList', 'TenantPackage'); +delete from sys_menu where name = 'TenantManagement'; + +drop table if exists sys_tenant_package_menu; +drop table if exists sys_tenant; +drop table if exists sys_tenant_package; diff --git a/sql/mysql/init.sql b/sql/mysql/init.sql new file mode 100644 index 0000000..4fb79e3 --- /dev/null +++ b/sql/mysql/init.sql @@ -0,0 +1,30 @@ +insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) +values +('租户管理', 'TenantManagement', '/tenant', 99, 'icon-park-outline:peoples', 0, null, null, 1, 1, 1, '', null, null, now(), null); + +set @dir_id = LAST_INSERT_ID(); + +insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) +values +('租户管理', 'TenantList', '/tenant/list', 1, null, 1, '/tenant/list/index', null, 1, 1, 1, '', null, @dir_id, now(), null); + +set @tenant_menu_id = LAST_INSERT_ID(); + +insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) +values +('套餐管理', 'TenantPackage', '/tenant/package', 2, null, 1, '/tenant/package/index', null, 1, 1, 1, '', null, @dir_id, now(), null); + +set @package_menu_id = LAST_INSERT_ID(); + +insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) +values +('新增', 'AddTenant', null, 1, null, 2, null, 'tenant:management:add', 1, 0, 1, '', null, @tenant_menu_id, now(), null), +('编辑', 'EditTenant', null, 2, null, 2, null, 'tenant:management:edit', 1, 0, 1, '', null, @tenant_menu_id, now(), null), +('删除', 'DeleteTenant', null, 3, null, 2, null, 'tenant:management:del', 1, 0, 1, '', null, @tenant_menu_id, now(), null), +('修改管理员密码', 'ResetTenantAdminPwd', null, 4, null, 2, null, 'tenant:management:pwd', 1, 0, 1, '', null, @tenant_menu_id, now(), null); + +insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) +values +('新增', 'AddTenantPackage', null, 1, null, 2, null, 'tenant:package:add', 1, 0, 1, '', null, @package_menu_id, now(), null), +('编辑', 'EditTenantPackage', null, 2, null, 2, null, 'tenant:package:edit', 1, 0, 1, '', null, @package_menu_id, now(), null), +('删除', 'DeleteTenantPackage', null, 3, null, 2, null, 'tenant:package:del', 1, 0, 1, '', null, @package_menu_id, now(), null); diff --git a/sql/mysql/init_snowflake.sql b/sql/mysql/init_snowflake.sql new file mode 100644 index 0000000..d0f77c4 --- /dev/null +++ b/sql/mysql/init_snowflake.sql @@ -0,0 +1,21 @@ +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 +(2139008052890697728, '租户管理', 'TenantManagement', '/tenant', 99, 'icon-park-outline:peoples', 0, null, 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) +values +(2139008052924252160, '租户管理', 'TenantList', '/tenant/list', 1, null, 1, '/tenant/list/index', null, 1, 1, 1, '', null, 2139008052890697728, now(), null), +(2139008052974583808, '套餐管理', 'TenantPackage', '/tenant/package', 2, null, 1, '/tenant/package/index', null, 1, 1, 1, '', null, 2139008052890697728, 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) +values +(2139008053175910400, '新增', 'AddTenant', null, 1, null, 2, null, 'tenant:management:add', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053238824960, '编辑', 'EditTenant', null, 2, null, 2, null, 'tenant:management:edit', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053301739520, '删除', 'DeleteTenant', null, 3, null, 2, null, 'tenant:management:del', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053364654080, '修改管理员密码', 'ResetTenantAdminPwd', null, 4, null, 2, null, 'tenant:management:pwd', 1, 0, 1, '', null, 2139008052924252160, 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) +values +(2139008053507260416, '新增', 'AddTenantPackage', null, 1, null, 2, null, 'tenant:package:add', 1, 0, 1, '', null, 2139008052974583808, now(), null), +(2139008053570174976, '编辑', 'EditTenantPackage', null, 2, null, 2, null, 'tenant:package:edit', 1, 0, 1, '', null, 2139008052974583808, now(), null), +(2139008053637283840, '删除', 'DeleteTenantPackage', null, 3, null, 2, null, 'tenant:package:del', 1, 0, 1, '', null, 2139008052974583808, now(), null); diff --git a/sql/postgresql/destroy.sql b/sql/postgresql/destroy.sql new file mode 100644 index 0000000..88b9a82 --- /dev/null +++ b/sql/postgresql/destroy.sql @@ -0,0 +1,15 @@ +delete from sys_user_role where tenant_id > 0; +delete from sys_role_menu where tenant_id > 0; +delete from sys_user where tenant_id > 0; +delete from sys_role where tenant_id > 0; +delete from sys_dept where tenant_id > 0; + +delete from sys_menu where name in ('AddTenant', 'EditTenant', 'DeleteTenant', 'ResetTenantAdminPwd', 'AddTenantPackage', 'EditTenantPackage', 'DeleteTenantPackage'); +delete from sys_menu where name in ('TenantList', 'TenantPackage'); +delete from sys_menu where name = 'TenantManagement'; + +drop table if exists sys_tenant_package_menu; +drop table if exists sys_tenant; +drop table if exists sys_tenant_package; + +select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu; diff --git a/sql/postgresql/destroy_snowflake.sql b/sql/postgresql/destroy_snowflake.sql new file mode 100644 index 0000000..9de173c --- /dev/null +++ b/sql/postgresql/destroy_snowflake.sql @@ -0,0 +1,13 @@ +delete from sys_user_role where tenant_id > 0; +delete from sys_role_menu where tenant_id > 0; +delete from sys_user where tenant_id > 0; +delete from sys_role where tenant_id > 0; +delete from sys_dept where tenant_id > 0; + +delete from sys_menu where name in ('AddTenant', 'EditTenant', 'DeleteTenant', 'ResetTenantAdminPwd', 'AddTenantPackage', 'EditTenantPackage', 'DeleteTenantPackage'); +delete from sys_menu where name in ('TenantList', 'TenantPackage'); +delete from sys_menu where name = 'TenantManagement'; + +drop table if exists sys_tenant_package_menu; +drop table if exists sys_tenant; +drop table if exists sys_tenant_package; diff --git a/sql/postgresql/init.sql b/sql/postgresql/init.sql new file mode 100644 index 0000000..c09c45c --- /dev/null +++ b/sql/postgresql/init.sql @@ -0,0 +1,33 @@ +do $$ +declare + dir_id bigint; + tenant_menu_id bigint; + package_menu_id bigint; +begin + insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) + values ('租户管理', 'TenantManagement', '/tenant', 99, 'icon-park-outline:peoples', 0, null, null, 1, 1, 1, '', null, null, now(), null) + returning id into dir_id; + + insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) + values ('租户管理', 'TenantList', '/tenant/list', 1, null, 1, '/tenant/list/index', null, 1, 1, 1, '', null, dir_id, now(), null) + returning id into tenant_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) + values ('套餐管理', 'TenantPackage', '/tenant/package', 2, null, 1, '/tenant/package/index', null, 1, 1, 1, '', null, dir_id, now(), null) + returning id into package_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) + values + ('新增', 'AddTenant', null, 1, null, 2, null, 'tenant:management:add', 1, 0, 1, '', null, tenant_menu_id, now(), null), + ('编辑', 'EditTenant', null, 2, null, 2, null, 'tenant:management:edit', 1, 0, 1, '', null, tenant_menu_id, now(), null), + ('删除', 'DeleteTenant', null, 3, null, 2, null, 'tenant:management:del', 1, 0, 1, '', null, tenant_menu_id, now(), null), + ('修改管理员密码', 'ResetTenantAdminPwd', null, 4, null, 2, null, 'tenant:management:pwd', 1, 0, 1, '', null, tenant_menu_id, now(), null); + + insert into sys_menu (title, name, path, sort, icon, type, component, perms, status, display, cache, link, remark, parent_id, created_time, updated_time) + values + ('新增', 'AddTenantPackage', null, 1, null, 2, null, 'tenant:package:add', 1, 0, 1, '', null, package_menu_id, now(), null), + ('编辑', 'EditTenantPackage', null, 2, null, 2, null, 'tenant:package:edit', 1, 0, 1, '', null, package_menu_id, now(), null), + ('删除', 'DeleteTenantPackage', null, 3, null, 2, null, 'tenant:package:del', 1, 0, 1, '', null, package_menu_id, now(), null); +end $$; + +select setval(pg_get_serial_sequence('sys_menu', 'id'), coalesce(max(id), 0) + 1, true) from sys_menu; diff --git a/sql/postgresql/init_snowflake.sql b/sql/postgresql/init_snowflake.sql new file mode 100644 index 0000000..d0f77c4 --- /dev/null +++ b/sql/postgresql/init_snowflake.sql @@ -0,0 +1,21 @@ +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 +(2139008052890697728, '租户管理', 'TenantManagement', '/tenant', 99, 'icon-park-outline:peoples', 0, null, 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) +values +(2139008052924252160, '租户管理', 'TenantList', '/tenant/list', 1, null, 1, '/tenant/list/index', null, 1, 1, 1, '', null, 2139008052890697728, now(), null), +(2139008052974583808, '套餐管理', 'TenantPackage', '/tenant/package', 2, null, 1, '/tenant/package/index', null, 1, 1, 1, '', null, 2139008052890697728, 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) +values +(2139008053175910400, '新增', 'AddTenant', null, 1, null, 2, null, 'tenant:management:add', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053238824960, '编辑', 'EditTenant', null, 2, null, 2, null, 'tenant:management:edit', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053301739520, '删除', 'DeleteTenant', null, 3, null, 2, null, 'tenant:management:del', 1, 0, 1, '', null, 2139008052924252160, now(), null), +(2139008053364654080, '修改管理员密码', 'ResetTenantAdminPwd', null, 4, null, 2, null, 'tenant:management:pwd', 1, 0, 1, '', null, 2139008052924252160, 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) +values +(2139008053507260416, '新增', 'AddTenantPackage', null, 1, null, 2, null, 'tenant:package:add', 1, 0, 1, '', null, 2139008052974583808, now(), null), +(2139008053570174976, '编辑', 'EditTenantPackage', null, 2, null, 2, null, 'tenant:package:edit', 1, 0, 1, '', null, 2139008052974583808, now(), null), +(2139008053637283840, '删除', 'DeleteTenantPackage', null, 3, null, 2, null, 'tenant:package:del', 1, 0, 1, '', null, 2139008052974583808, now(), null); From cc6a487e34353cbc9afb8cf3692d45233c01eca4 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 8 Mar 2026 21:58:59 +0800 Subject: [PATCH 2/8] Add release ci --- .github/workflows/release.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2baad5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,17 @@ +name: Plugin Release + +on: + push: + tags: + - "v*" + +jobs: + update-submodule: + runs-on: ubuntu-latest + steps: + - name: Update plugin submodule + uses: fastapi-practices/plugin-release@v1 + with: + push-to: fastapi-practices/plugins + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} \ No newline at end of file From 394827e7d1210f35c17fa30d73c1b70cdaa3d6ef Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 8 Mar 2026 22:20:48 +0800 Subject: [PATCH 3/8] Fix package id index --- model/tenant.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/model/tenant.py b/model/tenant.py index 340c8aa..75af5bd 100644 --- a/model/tenant.py +++ b/model/tenant.py @@ -11,11 +11,12 @@ class Tenant(Base): """租户表""" __tablename__ = 'sys_tenant' + __table_args__ = (sa.Index('ix_sys_tenant_pkg_id', 'package_id'),) id: Mapped[id_key] = mapped_column(init=False) name: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='租户名称') code: Mapped[str] = mapped_column(sa.String(64), unique=True, index=True, comment='租户编码') - package_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='套餐 ID') + package_id: Mapped[int] = mapped_column(sa.BigInteger, comment='套餐 ID') admin_user_id: Mapped[int | None] = mapped_column(sa.BigInteger, init=False, default=None, comment='管理员用户 ID') admin_username: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='管理员用户名') contact: Mapped[str | None] = mapped_column(sa.String(64), default=None, comment='联系人') From 8239882b970e3113b0ea6405c9625c12378b8fd4 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 12 Mar 2026 23:13:27 +0800 Subject: [PATCH 4/8] Fix and improve the issue --- crud/crud_package.py | 5 +- crud/crud_tenant.py | 169 ++++++++++++++++++++++++++++++++++++- filter.py | 14 --- listener.py | 7 +- plugin.toml | 1 - schema/package.py | 10 +-- schema/tenant.py | 11 +-- service/package_service.py | 41 +++------ service/tenant_service.py | 125 +++++++-------------------- utils.py | 15 ++++ 10 files changed, 233 insertions(+), 165 deletions(-) delete mode 100644 filter.py create mode 100644 utils.py diff --git a/crud/crud_package.py b/crud/crud_package.py index 76d4cef..0073699 100644 --- a/crud/crud_package.py +++ b/crud/crud_package.py @@ -71,7 +71,8 @@ async def create(self, db: AsyncSession, obj: CreateTenantPackageParam) -> Tenan :return: """ dict_obj = obj.model_dump(exclude={'menus'}) - new_package = await self.create_model(db, dict_obj) + new_package = self.model(**dict_obj) + db.add(new_package) await db.flush() return new_package @@ -84,7 +85,7 @@ async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantPackageParam) :param obj: 更新套餐参数 :return: """ - dict_obj = obj.model_dump(exclude={'menus'}, exclude_none=True) + dict_obj = obj.model_dump(exclude={'menus'}) return await self.update_model(db, pk, dict_obj) async def delete(self, db: AsyncSession, pk: int) -> int: diff --git a/crud/crud_tenant.py b/crud/crud_tenant.py index e8a93b3..c26d9a7 100644 --- a/crud/crud_tenant.py +++ b/crud/crud_tenant.py @@ -1,9 +1,14 @@ from collections.abc import Sequence -from sqlalchemy import Select +from sqlalchemy import Select, delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model import Dept, Role, User +from backend.app.admin.model.login_log import LoginLog +from backend.app.admin.model.m2m import role_data_scope, role_menu, user_role +from backend.app.admin.model.opera_log import OperaLog +from backend.app.admin.model.user_password_history import UserPasswordHistory from backend.plugin.tenant.model import Tenant from backend.plugin.tenant.schema.tenant import CreateTenantParam, UpdateTenantParam @@ -103,7 +108,8 @@ async def create(self, db: AsyncSession, obj: CreateTenantParam, code: str) -> T """ dict_obj = obj.model_dump(exclude={'admin_username', 'admin_password'}) dict_obj.update({'code': code, 'admin_username': obj.admin_username}) - new_tenant = await self.create_model(db, dict_obj) + new_tenant = self.model(**dict_obj) + db.add(new_tenant) await db.flush() return new_tenant @@ -149,5 +155,164 @@ async def get_ids_by_package_id(self, db: AsyncSession, package_id: int) -> list tenants = await self.select_models(db, package_id=package_id) return [t.id for t in tenants] + @staticmethod + async def replace_role_menus(db: AsyncSession, *, role_id: int, tenant_id: int, menu_ids: list[int]) -> None: + """ + 重建角色菜单关联 + + :param db: 数据库会话 + :param role_id: 角色 ID + :param tenant_id: 租户 ID + :param menu_ids: 菜单 ID 列表 + :return: + """ + await db.execute(delete(role_menu).where(role_menu.c.role_id == role_id, role_menu.c.tenant_id == tenant_id)) + + if menu_ids: + await db.execute( + insert(role_menu), + [{'role_id': role_id, 'menu_id': menu_id, 'tenant_id': tenant_id} for menu_id in menu_ids], + ) + + @staticmethod + async def sync_admin_role_menus(db: AsyncSession, *, tenant_id: int, role_name: str, menu_ids: list[int]) -> bool: + """ + 同步租户管理员角色菜单 + + :param db: 数据库会话 + :param tenant_id: 租户 ID + :param role_name: 角色名称 + :param menu_ids: 菜单 ID 列表 + :return: + """ + stmt = select(Role).where(Role.tenant_id == tenant_id, Role.name == role_name) + admin_role = (await db.execute(stmt)).scalars().first() + if not admin_role: + return False + + await CRUDTenant.replace_role_menus(db, role_id=admin_role.id, tenant_id=tenant_id, menu_ids=menu_ids) + return True + + @staticmethod + async def initialize_related_data( + db: AsyncSession, + *, + tenant: Tenant, + admin_username: str, + hashed_password: str, + salt: bytes, + role_name: str, + menu_ids: list[int], + ) -> None: + """ + 初始化租户关联数据 + + :param db: 数据库会话 + :param tenant: 租户对象 + :param admin_username: 管理员用户名 + :param hashed_password: 加密密码 + :param salt: 密码盐 + :param role_name: 管理员角色名称 + :param menu_ids: 菜单 ID 列表 + :return: + """ + dept = Dept( + name=tenant.name, + sort=0, + status=1, + del_flag=False, + tenant_id=tenant.id, + ) + db.add(dept) + await db.flush() + + role = Role( + name=role_name, + status=1, + is_filter_scopes=False, + tenant_id=tenant.id, + ) + db.add(role) + await db.flush() + + await CRUDTenant.replace_role_menus(db, role_id=role.id, tenant_id=tenant.id, menu_ids=menu_ids) + + admin_user = User( + username=admin_username, + nickname=f'{tenant.name}管理员', + password=hashed_password, + salt=salt, + status=1, + is_superuser=False, + is_staff=True, + is_multi_login=False, + dept_id=dept.id, + tenant_id=tenant.id, + ) + db.add(admin_user) + await db.flush() + + await db.execute( + insert(user_role), + [{'user_id': admin_user.id, 'role_id': role.id, 'tenant_id': tenant.id}], + ) + + await db.execute( + update(Tenant) + .where(Tenant.id == tenant.id) + .values(admin_user_id=admin_user.id, admin_username=admin_username) + ) + + @staticmethod + async def update_admin_password(db: AsyncSession, *, user_id: int, hashed_password: str, salt: bytes) -> None: + """ + 更新租户管理员密码 + + :param db: 数据库会话 + :param user_id: 用户 ID + :param hashed_password: 加密密码 + :param salt: 密码盐 + :return: + """ + await db.execute(update(User).where(User.id == user_id).values(password=hashed_password, salt=salt)) + + @staticmethod + async def delete_related_data(db: AsyncSession, *, tenant_id: int) -> None: + """ + 删除租户关联数据 + + :param db: 数据库会话 + :param tenant_id: 租户 ID + :return: + """ + await db.execute(delete(user_role).where(user_role.c.tenant_id == tenant_id)) + await db.execute(delete(role_menu).where(role_menu.c.tenant_id == tenant_id)) + await db.execute(delete(role_data_scope).where(role_data_scope.c.tenant_id == tenant_id)) + await db.execute(delete(UserPasswordHistory).where(UserPasswordHistory.tenant_id == tenant_id)) + await db.execute(delete(OperaLog).where(OperaLog.tenant_id == tenant_id)) + await db.execute(delete(LoginLog).where(LoginLog.tenant_id == tenant_id)) + + try: + from backend.plugin.oauth2.model.user_social import UserSocial + + user_stmt = select(User.id).where(User.tenant_id == tenant_id) + user_result = await db.execute(user_stmt) + user_ids = [row[0] for row in user_result.all()] + if user_ids: + await db.execute(delete(UserSocial).where(UserSocial.user_id.in_(user_ids))) + except ImportError: + pass + + try: + from backend.plugin.notice.model.notice import Notice + + await db.execute(delete(Notice).where(Notice.tenant_id == tenant_id)) + except ImportError: + pass + + await db.execute(delete(User).where(User.tenant_id == tenant_id)) + await db.execute(delete(Role).where(Role.tenant_id == tenant_id)) + await db.execute(delete(Dept).where(Dept.tenant_id == tenant_id)) + tenant_dao: CRUDTenant = CRUDTenant(Tenant) diff --git a/filter.py b/filter.py deleted file mode 100644 index 687c147..0000000 --- a/filter.py +++ /dev/null @@ -1,14 +0,0 @@ -from backend.common.context import ctx - - -def get_tenant_dict(obj: dict) -> dict: - """ - 向数据字典中注入 tenant_id - - :param obj: 数据字典 - :return: - """ - tenant_id = ctx.tenant_id - if tenant_id is not None and 'tenant_id' not in obj: - obj['tenant_id'] = tenant_id - return obj diff --git a/listener.py b/listener.py index 6d240f9..39aaca2 100644 --- a/listener.py +++ b/listener.py @@ -10,19 +10,16 @@ def register_tenant_sqlalchemy_listeners() -> None: @event.listens_for(Session, 'do_orm_execute', propagate=True) def _inject_tenant_filter(orm_execute_state: ORMExecuteState) -> None: + """为查询语句自动追加租户过滤条件""" if not orm_execute_state.is_select: return tenant_id = ctx.tenant_id - if tenant_id is None: - return mapper = orm_execute_state.bind_mapper if mapper and hasattr(mapper.entity, 'tenant_id'): orm_execute_state.statement = orm_execute_state.statement.where(mapper.entity.tenant_id == tenant_id) @event.listens_for(TenantMixin, 'before_insert', propagate=True) def _inject_tenant_id(mapper, connection, target) -> None: # noqa: ANN001 - tenant_id = ctx.tenant_id - if tenant_id is None: - return + """为新增模型自动注入当前租户 ID""" if hasattr(target, 'tenant_id') and target.tenant_id is None: target.tenant_id = ctx.tenant_id diff --git a/plugin.toml b/plugin.toml index 47e46b4..9674c00 100644 --- a/plugin.toml +++ b/plugin.toml @@ -11,4 +11,3 @@ router = ["v1"] [settings] TENANT_ADMIN_DEFAULT_ROLE_NAME = "租户管理员" -TENANT_DEFAULT_ID = 0 diff --git a/schema/package.py b/schema/package.py index a3c615f..65846fc 100644 --- a/schema/package.py +++ b/schema/package.py @@ -21,14 +21,10 @@ class CreateTenantPackageParam(TenantPackageSchemaBase): menus: list[int] = Field(description='菜单 ID 列表') -class UpdateTenantPackageParam(SchemaBase): +class UpdateTenantPackageParam(TenantPackageSchemaBase): """更新租户套餐参数""" - name: str | None = Field(None, description='套餐名称') - sort: int | None = Field(None, description='排序') - status: StatusType | None = Field(None, description='状态') - menus: list[int] | None = Field(None, description='菜单 ID 列表') - remark: str | None = Field(None, description='备注') + menus: list[int] = Field(description='菜单 ID 列表') class GetTenantPackageDetail(TenantPackageSchemaBase): @@ -40,4 +36,4 @@ class GetTenantPackageDetail(TenantPackageSchemaBase): created_time: datetime = Field(description='创建时间') updated_time: datetime | None = Field(None, description='更新时间') - menu_ids: list[int] = Field(default=[], description='关联的菜单 ID 列表') + menu_ids: list[int] = Field(default_factory=list, description='关联的菜单 ID 列表') diff --git a/schema/tenant.py b/schema/tenant.py index 437c786..414556f 100644 --- a/schema/tenant.py +++ b/schema/tenant.py @@ -26,18 +26,9 @@ class CreateTenantParam(TenantSchemaBase): admin_password: str = Field(description='管理员密码') -class UpdateTenantParam(SchemaBase): +class UpdateTenantParam(TenantSchemaBase): """更新租户参数""" - package_id: int | None = Field(None, description='套餐 ID') - name: str | None = Field(None, description='租户名称') - contact: str | None = Field(None, description='联系人') - phone: str | None = Field(None, description='手机号') - domain: str | None = Field(None, description='租户域名') - expire_time: datetime | None = Field(None, description='过期时间') - status: StatusType | None = Field(None, description='状态') - remark: str | None = Field(None, description='备注') - class UpdateTenantAdminPwdParam(SchemaBase): """修改租户管理员密码参数""" diff --git a/service/package_service.py b/service/package_service.py index e67e69a..00cb38f 100644 --- a/service/package_service.py +++ b/service/package_service.py @@ -1,10 +1,7 @@ from typing import Any -from sqlalchemy import delete, insert, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.admin.model import Role -from backend.app.admin.model.m2m import role_menu from backend.common.exception import errors from backend.common.pagination import paging_data from backend.core.conf import settings @@ -68,36 +65,24 @@ async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantPackageParam) -> if not package: raise errors.NotFoundError(msg='套餐不存在') - if obj.name and obj.name != package.name: + if obj.name != package.name: existing = await tenant_package_dao.get_by_name(db, obj.name) if existing: raise errors.ForbiddenError(msg='套餐名称已存在') count = await tenant_package_dao.update(db, pk, obj) - - if obj.menus is not None: - await tenant_package_dao.update_menus(db, pk, obj.menus) - - tenant_ids = await tenant_dao.get_ids_by_package_id(db, pk) - for tenant_id in tenant_ids: - stmt = select(Role).where( - Role.tenant_id == tenant_id, Role.name == settings.TENANT_ADMIN_DEFAULT_ROLE_NAME - ) - admin_role = (await db.execute(stmt)).scalars().first() - if not admin_role: - continue - - await db.execute( - delete(role_menu).where(role_menu.c.role_id == admin_role.id, role_menu.c.tenant_id == tenant_id) - ) - - if obj.menus: - menu_data = [ - {'role_id': admin_role.id, 'menu_id': menu_id, 'tenant_id': tenant_id} for menu_id in obj.menus - ] - await db.execute(insert(role_menu), menu_data) - - return count + await tenant_package_dao.update_menus(db, pk, obj.menus) + + tenant_ids = await tenant_dao.get_ids_by_package_id(db, pk) + for tenant_id in tenant_ids: + await tenant_dao.sync_admin_role_menus( + db, + tenant_id=tenant_id, + role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids=obj.menus, + ) + + return count or 1 @staticmethod async def delete(*, db: AsyncSession, pk: int) -> int: diff --git a/service/tenant_service.py b/service/tenant_service.py index 385a131..683811a 100644 --- a/service/tenant_service.py +++ b/service/tenant_service.py @@ -3,14 +3,9 @@ import bcrypt from fast_captcha import text_captcha -from sqlalchemy import Select, delete, insert, select, update +from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.admin.model import Dept, Role, User -from backend.app.admin.model.login_log import LoginLog -from backend.app.admin.model.m2m import role_data_scope, role_menu, user_role -from backend.app.admin.model.opera_log import OperaLog -from backend.app.admin.model.user_password_history import UserPasswordHistory from backend.app.admin.utils.password_security import get_hash_password from backend.common.exception import errors from backend.common.pagination import paging_data @@ -92,6 +87,9 @@ async def create(*, db: AsyncSession, obj: CreateTenantParam) -> None: if existing_domain: raise errors.ForbiddenError(msg='租户域名已存在') + if len(obj.admin_password) < 6: + raise errors.RequestError(msg='管理员密码长度不能少于6位') + package = await tenant_package_dao.get(db, obj.package_id) if not package: raise errors.NotFoundError(msg='套餐不存在') @@ -103,99 +101,57 @@ async def create(*, db: AsyncSession, obj: CreateTenantParam) -> None: code = text_captcha(6) tenant = await tenant_dao.create(db, obj, code) - tenant_id = tenant.id - - dept = Dept( - name=tenant.name, - sort=0, - status=1, - del_flag=False, - tenant_id=tenant_id, - ) - db.add(dept) - await db.flush() - - role = Role( - name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, - status=1, - is_filter_scopes=False, - tenant_id=tenant_id, - ) - db.add(role) - await db.flush() - - menus = await tenant_package_dao.get_menus(db, tenant.package_id) - if menus: - menu_data = [{'role_id': role.id, 'menu_id': menu.id, 'tenant_id': tenant_id} for menu in menus] - await db.execute(insert(role_menu), menu_data) - salt = bcrypt.gensalt() hashed_password = get_hash_password(obj.admin_password, salt) - admin_user = User( - username=obj.admin_username, - nickname=f'{tenant.name}管理员', - password=hashed_password, + menu_ids = await tenant_package_dao.get_menu_ids(db, tenant.package_id) + await tenant_dao.initialize_related_data( + db, + tenant=tenant, + admin_username=obj.admin_username, + hashed_password=hashed_password, salt=salt, - status=1, - is_superuser=False, - is_staff=True, - is_multi_login=False, - dept_id=dept.id, - tenant_id=tenant_id, - ) - db.add(admin_user) - await db.flush() - - await db.execute( - insert(user_role), - [{'user_id': admin_user.id, 'role_id': role.id, 'tenant_id': tenant_id}], - ) - - await db.execute( - update(Tenant) - .where(Tenant.id == tenant_id) - .values(admin_user_id=admin_user.id, admin_username=obj.admin_username) + role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids=menu_ids, ) @staticmethod - async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: # noqa: C901 + async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: tenant = await tenant_dao.get(db, pk) if not tenant: raise errors.NotFoundError(msg='租户不存在') - if obj.name and obj.name != tenant.name: + if obj.name != tenant.name: existing = await tenant_dao.get_by_name(db, obj.name) if existing: raise errors.ForbiddenError(msg='租户名称已存在') - if obj.domain and obj.domain != tenant.domain: + if obj.domain is not None and obj.domain != tenant.domain: existing_domain = await tenant_dao.get_by_domain(db, obj.domain) if existing_domain: raise errors.ForbiddenError(msg='租户域名已存在') - if obj.package_id and obj.package_id != tenant.package_id: + if obj.package_id != tenant.package_id: package = await tenant_package_dao.get(db, obj.package_id) if not package: raise errors.NotFoundError(msg='套餐不存在') if package.status == 0: raise errors.ForbiddenError(msg='租户套餐已被禁用') - stmt = select(Role).where(Role.tenant_id == pk, Role.name == settings.TENANT_ADMIN_DEFAULT_ROLE_NAME) - admin_role = (await db.execute(stmt)).scalars().first() - if admin_role: - await db.execute( - delete(role_menu).where(role_menu.c.role_id == admin_role.id, role_menu.c.tenant_id == pk) - ) - - menus = await tenant_package_dao.get_menus(db, obj.package_id) - if menus: - menu_data = [{'role_id': admin_role.id, 'menu_id': menu.id, 'tenant_id': pk} for menu in menus] - await db.execute(insert(role_menu), menu_data) + menu_ids = await tenant_package_dao.get_menu_ids(db, obj.package_id) + await tenant_dao.sync_admin_role_menus( + db, + tenant_id=pk, + role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids=menu_ids, + ) return await tenant_dao.update(db, pk, obj) @staticmethod async def update_admin_password(*, db: AsyncSession, pk: int, password: str) -> None: + if len(password) < 6: + raise errors.RequestError(msg='密码长度不能少于6位') + tenant = await tenant_dao.get(db, pk) if not tenant: raise errors.NotFoundError(msg='租户不存在') @@ -204,8 +160,8 @@ async def update_admin_password(*, db: AsyncSession, pk: int, password: str) -> salt = bcrypt.gensalt() hashed_password = get_hash_password(password, salt) - await db.execute( - update(User).where(User.id == tenant.admin_user_id).values(password=hashed_password, salt=salt) + await tenant_dao.update_admin_password( + db, user_id=tenant.admin_user_id, hashed_password=hashed_password, salt=salt ) @staticmethod @@ -215,30 +171,7 @@ async def delete(*, db: AsyncSession, obj: DeleteTenantParam) -> int: if not tenant: continue - await db.execute(delete(user_role).where(user_role.c.tenant_id == pk)) - await db.execute(delete(role_menu).where(role_menu.c.tenant_id == pk)) - await db.execute(delete(role_data_scope).where(role_data_scope.c.tenant_id == pk)) - await db.execute(delete(UserPasswordHistory).where(UserPasswordHistory.tenant_id == pk)) - await db.execute(delete(OperaLog).where(OperaLog.tenant_id == pk)) - await db.execute(delete(LoginLog).where(LoginLog.tenant_id == pk)) - - try: - from backend.plugin.oauth2.model.user_social import UserSocial - - await db.execute(delete(UserSocial).where(UserSocial.tenant_id == pk)) - except ImportError: - pass - - try: - from backend.plugin.notice.model.notice import Notice - - await db.execute(delete(Notice).where(Notice.tenant_id == pk)) - except ImportError: - pass - - await db.execute(delete(User).where(User.tenant_id == pk)) - await db.execute(delete(Role).where(Role.tenant_id == pk)) - await db.execute(delete(Dept).where(Dept.tenant_id == pk)) + await tenant_dao.delete_related_data(db, tenant_id=pk) return await tenant_dao.delete(db, obj.pks) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..526b400 --- /dev/null +++ b/utils.py @@ -0,0 +1,15 @@ +from typing import Any + +from backend.common.context import ctx + + +def get_tenant_dict(obj: dict[str, Any]) -> dict[str, Any]: + """ + 向数据字典中注入 tenant_id + + :param obj: 数据字典 + :return: + """ + if 'tenant_id' not in obj: + obj['tenant_id'] = ctx.tenant_id + return obj From 36ad5f382048caedced709f4dd850d375306cef3 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 15 Mar 2026 01:34:05 +0800 Subject: [PATCH 5/8] Fix known issues and improvements --- api/v1/package.py | 6 ++--- api/v1/tenant.py | 12 ++++++---- crud/crud_tenant.py | 45 ++++++++++++++++++++++---------------- listener.py | 12 ++++++---- service/package_service.py | 8 +++---- service/tenant_service.py | 28 +++--------------------- 6 files changed, 50 insertions(+), 61 deletions(-) diff --git a/api/v1/package.py b/api/v1/package.py index f61ac57..ba8a77b 100644 --- a/api/v1/package.py +++ b/api/v1/package.py @@ -3,13 +3,12 @@ from fastapi import APIRouter, Depends, Path, Query from backend.app.admin.schema.menu import GetMenuTree -from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.pagination import DependsPagination, PageData from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission from backend.common.security.rbac import DependsRBAC from backend.database.db import CurrentSession, CurrentSessionTransaction -from backend.plugin.tenant.crud.crud_package import tenant_package_dao from backend.plugin.tenant.schema.package import ( CreateTenantPackageParam, GetTenantPackageDetail, @@ -49,8 +48,7 @@ async def get_tenant_packages_paginated( name: Annotated[str | None, Query(description='套餐名称')] = None, status: Annotated[int | None, Query(description='状态')] = None, ) -> ResponseSchemaModel[PageData[GetTenantPackageDetail]]: - package_select = await tenant_package_dao.get_select(name=name, status=status) - page_data = await paging_data(db, package_select) + page_data = await tenant_package_service.get_list(db=db, name=name, status=status) return response_base.success(data=page_data) diff --git a/api/v1/tenant.py b/api/v1/tenant.py index ad4c26d..9510b5d 100644 --- a/api/v1/tenant.py +++ b/api/v1/tenant.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Path, Query -from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.pagination import DependsPagination, PageData from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission @@ -59,10 +59,14 @@ async def get_tenants_paginated( package_id: Annotated[int | None, Query(description='套餐 ID')] = None, status: Annotated[int | None, Query(description='状态')] = None, ) -> ResponseSchemaModel[PageData[GetTenantDetail]]: - tenant_select = await tenant_service.get_select( - name=name, code=code, domain=domain, package_id=package_id, status=status + page_data = await tenant_service.get_list( + db=db, + name=name, + code=code, + domain=domain, + package_id=package_id, + status=status, ) - page_data = await paging_data(db, tenant_select) return response_base.success(data=page_data) diff --git a/crud/crud_tenant.py b/crud/crud_tenant.py index c26d9a7..39b1923 100644 --- a/crud/crud_tenant.py +++ b/crud/crud_tenant.py @@ -1,5 +1,6 @@ from collections.abc import Sequence +import bcrypt from sqlalchemy import Select, delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus @@ -9,6 +10,8 @@ from backend.app.admin.model.m2m import role_data_scope, role_menu, user_role from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.user_password_history import UserPasswordHistory +from backend.app.admin.utils.password_security import get_hash_password +from backend.common.context import ctx from backend.plugin.tenant.model import Tenant from backend.plugin.tenant.schema.tenant import CreateTenantParam, UpdateTenantParam @@ -110,7 +113,6 @@ async def create(self, db: AsyncSession, obj: CreateTenantParam, code: str) -> T dict_obj.update({'code': code, 'admin_username': obj.admin_username}) new_tenant = self.model(**dict_obj) db.add(new_tenant) - await db.flush() return new_tenant async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: @@ -174,8 +176,7 @@ async def replace_role_menus(db: AsyncSession, *, role_id: int, tenant_id: int, [{'role_id': role_id, 'menu_id': menu_id, 'tenant_id': tenant_id} for menu_id in menu_ids], ) - @staticmethod - async def sync_admin_role_menus(db: AsyncSession, *, tenant_id: int, role_name: str, menu_ids: list[int]) -> bool: + async def sync_admin_role_menus(self, db: AsyncSession, *, tenant_id: int, role_name: str, menu_ids: list[int]) -> bool: """ 同步租户管理员角色菜单 @@ -185,22 +186,26 @@ async def sync_admin_role_menus(db: AsyncSession, *, tenant_id: int, role_name: :param menu_ids: 菜单 ID 列表 :return: """ - stmt = select(Role).where(Role.tenant_id == tenant_id, Role.name == role_name) - admin_role = (await db.execute(stmt)).scalars().first() - if not admin_role: - return False + current_tenant_id = ctx.tenant_id + ctx.tenant_id = tenant_id + try: + stmt = select(Role).where(Role.tenant_id == tenant_id, Role.name == role_name) + admin_role = (await db.execute(stmt)).scalars().first() + if not admin_role: + return False - await CRUDTenant.replace_role_menus(db, role_id=admin_role.id, tenant_id=tenant_id, menu_ids=menu_ids) - return True + await self.replace_role_menus(db, role_id=admin_role.id, tenant_id=tenant_id, menu_ids=menu_ids) + return True + finally: + ctx.tenant_id = current_tenant_id - @staticmethod - async def initialize_related_data( + async def init_related_data( + self, db: AsyncSession, *, tenant: Tenant, admin_username: str, - hashed_password: str, - salt: bytes, + admin_password: str, role_name: str, menu_ids: list[int], ) -> None: @@ -210,8 +215,7 @@ async def initialize_related_data( :param db: 数据库会话 :param tenant: 租户对象 :param admin_username: 管理员用户名 - :param hashed_password: 加密密码 - :param salt: 密码盐 + :param admin_password: 管理员密码 :param role_name: 管理员角色名称 :param menu_ids: 菜单 ID 列表 :return: @@ -235,8 +239,10 @@ async def initialize_related_data( db.add(role) await db.flush() - await CRUDTenant.replace_role_menus(db, role_id=role.id, tenant_id=tenant.id, menu_ids=menu_ids) + await self.replace_role_menus(db, role_id=role.id, tenant_id=tenant.id, menu_ids=menu_ids) + salt = bcrypt.gensalt() + hashed_password = get_hash_password(admin_password, salt) admin_user = User( username=admin_username, nickname=f'{tenant.name}管理员', @@ -264,16 +270,17 @@ async def initialize_related_data( ) @staticmethod - async def update_admin_password(db: AsyncSession, *, user_id: int, hashed_password: str, salt: bytes) -> None: + async def update_admin_password(db: AsyncSession, *, user_id: int, password: str) -> None: """ 更新租户管理员密码 :param db: 数据库会话 :param user_id: 用户 ID - :param hashed_password: 加密密码 - :param salt: 密码盐 + :param password: 密码 :return: """ + salt = bcrypt.gensalt() + hashed_password = get_hash_password(password, salt) await db.execute(update(User).where(User.id == user_id).values(password=hashed_password, salt=salt)) @staticmethod diff --git a/listener.py b/listener.py index 39aaca2..b2e3a3d 100644 --- a/listener.py +++ b/listener.py @@ -1,5 +1,5 @@ from sqlalchemy import event -from sqlalchemy.orm import ORMExecuteState, Session +from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria from backend.common.context import ctx from backend.common.model import TenantMixin @@ -14,9 +14,13 @@ def _inject_tenant_filter(orm_execute_state: ORMExecuteState) -> None: if not orm_execute_state.is_select: return tenant_id = ctx.tenant_id - mapper = orm_execute_state.bind_mapper - if mapper and hasattr(mapper.entity, 'tenant_id'): - orm_execute_state.statement = orm_execute_state.statement.where(mapper.entity.tenant_id == tenant_id) + orm_execute_state.statement = orm_execute_state.statement.options( + with_loader_criteria( + TenantMixin, + lambda cls: cls.tenant_id == tenant_id, + include_aliases=True, + ) + ) @event.listens_for(TenantMixin, 'before_insert', propagate=True) def _inject_tenant_id(mapper, connection, target) -> None: # noqa: ANN001 diff --git a/service/package_service.py b/service/package_service.py index 00cb38f..fb87673 100644 --- a/service/package_service.py +++ b/service/package_service.py @@ -1,5 +1,6 @@ from typing import Any +from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from backend.common.exception import errors @@ -18,14 +19,11 @@ class TenantPackageService: @staticmethod - async def get(*, db: AsyncSession, pk: int) -> dict[str, Any]: + async def get(*, db: AsyncSession, pk: int) -> TenantPackage: package = await tenant_package_dao.get(db, pk) if not package: raise errors.NotFoundError(msg='套餐不存在') - menu_ids = await tenant_package_dao.get_menu_ids(db, pk) - data = GetTenantPackageDetail.model_validate(package).model_dump() - data['menu_ids'] = menu_ids - return data + return package @staticmethod async def get_menu_tree(*, db: AsyncSession, pk: int) -> list[dict[str, Any] | None]: diff --git a/service/tenant_service.py b/service/tenant_service.py index 683811a..f1d17bf 100644 --- a/service/tenant_service.py +++ b/service/tenant_service.py @@ -40,23 +40,6 @@ async def get_id_by_domain(*, db: AsyncSession, domain: str) -> int | None: tenant = await tenant_dao.get_by_domain(db, domain) return tenant.id if tenant else None - @staticmethod - async def get_select( - *, - name: str | None = None, - code: str | None = None, - domain: str | None = None, - package_id: int | None = None, - status: int | None = None, - ) -> Select: - return await tenant_dao.get_select( - name=name, - code=code, - domain=domain, - package_id=package_id, - status=status, - ) - @staticmethod async def get_list( *, @@ -101,15 +84,12 @@ async def create(*, db: AsyncSession, obj: CreateTenantParam) -> None: code = text_captcha(6) tenant = await tenant_dao.create(db, obj, code) - salt = bcrypt.gensalt() - hashed_password = get_hash_password(obj.admin_password, salt) menu_ids = await tenant_package_dao.get_menu_ids(db, tenant.package_id) - await tenant_dao.initialize_related_data( + await tenant_dao.init_related_data( db, tenant=tenant, admin_username=obj.admin_username, - hashed_password=hashed_password, - salt=salt, + admin_password=obj.admin_password, role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, menu_ids=menu_ids, ) @@ -158,10 +138,8 @@ async def update_admin_password(*, db: AsyncSession, pk: int, password: str) -> if not tenant.admin_user_id: raise errors.NotFoundError(msg='租户管理员用户不存在') - salt = bcrypt.gensalt() - hashed_password = get_hash_password(password, salt) await tenant_dao.update_admin_password( - db, user_id=tenant.admin_user_id, hashed_password=hashed_password, salt=salt + db, user_id=tenant.admin_user_id, password=password ) @staticmethod From a5188d739aa7d42dcdd6d63f6dd71ad480801265 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 15 Mar 2026 17:42:04 +0800 Subject: [PATCH 6/8] Optimize permission logic --- crud/crud_tenant.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crud/crud_tenant.py b/crud/crud_tenant.py index 39b1923..1b077e3 100644 --- a/crud/crud_tenant.py +++ b/crud/crud_tenant.py @@ -10,6 +10,7 @@ from backend.app.admin.model.m2m import role_data_scope, role_menu, user_role from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.user_password_history import UserPasswordHistory +from backend.app.admin.utils.cache import user_cache_manager from backend.app.admin.utils.password_security import get_hash_password from backend.common.context import ctx from backend.plugin.tenant.model import Tenant @@ -113,6 +114,7 @@ async def create(self, db: AsyncSession, obj: CreateTenantParam, code: str) -> T dict_obj.update({'code': code, 'admin_username': obj.admin_username}) new_tenant = self.model(**dict_obj) db.add(new_tenant) + await db.flush() return new_tenant async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: @@ -195,6 +197,7 @@ async def sync_admin_role_menus(self, db: AsyncSession, *, tenant_id: int, role_ return False await self.replace_role_menus(db, role_id=admin_role.id, tenant_id=tenant_id, menu_ids=menu_ids) + await user_cache_manager.clear_by_role_id(db, [admin_role.id]) return True finally: ctx.tenant_id = current_tenant_id From 7ee453def61582de9176d5750d69d00e77b9911e Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 15 Mar 2026 18:30:57 +0800 Subject: [PATCH 7/8] Fix lint --- crud/crud_tenant.py | 146 +++++++++++++++++++------------------ service/package_service.py | 8 +- service/tenant_service.py | 26 +++---- 3 files changed, 89 insertions(+), 91 deletions(-) diff --git a/crud/crud_tenant.py b/crud/crud_tenant.py index 1b077e3..3e55554 100644 --- a/crud/crud_tenant.py +++ b/crud/crud_tenant.py @@ -1,6 +1,7 @@ from collections.abc import Sequence import bcrypt + from sqlalchemy import Select, delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus @@ -58,6 +59,27 @@ async def get_by_domain(self, db: AsyncSession, domain: str) -> Tenant | None: """ return await self.select_model_by_column(db, domain=domain) + async def get_by_package_id(self, db: AsyncSession, package_id: int) -> Tenant | None: + """ + 通过套餐 ID 获取租户 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + return await self.select_model_by_column(db, package_id=package_id) + + async def get_ids_by_package_id(self, db: AsyncSession, package_id: int) -> list[int]: + """ + 通过套餐 ID 获取租户 ID 列表 + + :param db: 数据库会话 + :param package_id: 套餐 ID + :return: + """ + tenants = await self.select_models(db, package_id=package_id) + return [t.id for t in tenants] + async def get_select( self, *, @@ -128,39 +150,70 @@ async def update(self, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int """ return await self.update_model(db, pk, obj) - async def delete(self, db: AsyncSession, pks: list[int]) -> int: + @staticmethod + async def update_admin_password(db: AsyncSession, user_id: int, password: str) -> None: """ - 批量删除租户 + 更新租户管理员密码 :param db: 数据库会话 - :param pks: 租户 ID 列表 + :param user_id: 用户 ID + :param password: 密码 :return: """ - return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks) + salt = bcrypt.gensalt() + hashed_password = get_hash_password(password, salt) + await db.execute(update(User).where(User.id == user_id).values(password=hashed_password, salt=salt)) - async def get_by_package_id(self, db: AsyncSession, package_id: int) -> Tenant | None: + async def delete(self, db: AsyncSession, pks: list[int]) -> int: """ - 通过套餐 ID 获取租户 + 批量删除租户 :param db: 数据库会话 - :param package_id: 套餐 ID + :param pks: 租户 ID 列表 :return: """ - return await self.select_model_by_column(db, package_id=package_id) + return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks) - async def get_ids_by_package_id(self, db: AsyncSession, package_id: int) -> list[int]: + @staticmethod + async def delete_related_data(db: AsyncSession, tenant_id: int) -> None: """ - 通过套餐 ID 获取租户 ID 列表 + 删除租户关联数据 :param db: 数据库会话 - :param package_id: 套餐 ID + :param tenant_id: 租户 ID :return: """ - tenants = await self.select_models(db, package_id=package_id) - return [t.id for t in tenants] + await db.execute(delete(user_role).where(user_role.c.tenant_id == tenant_id)) + await db.execute(delete(role_menu).where(role_menu.c.tenant_id == tenant_id)) + await db.execute(delete(role_data_scope).where(role_data_scope.c.tenant_id == tenant_id)) + await db.execute(delete(UserPasswordHistory).where(UserPasswordHistory.tenant_id == tenant_id)) + await db.execute(delete(OperaLog).where(OperaLog.tenant_id == tenant_id)) + await db.execute(delete(LoginLog).where(LoginLog.tenant_id == tenant_id)) + + try: + from backend.plugin.oauth2.model.user_social import UserSocial + + user_stmt = select(User.id).where(User.tenant_id == tenant_id) + user_result = await db.execute(user_stmt) + user_ids = [row[0] for row in user_result.all()] + if user_ids: + await db.execute(delete(UserSocial).where(UserSocial.user_id.in_(user_ids))) + except ImportError: + pass + + try: + from backend.plugin.notice.model.notice import Notice + + await db.execute(delete(Notice).where(Notice.tenant_id == tenant_id)) + except ImportError: + pass + + await db.execute(delete(User).where(User.tenant_id == tenant_id)) + await db.execute(delete(Role).where(Role.tenant_id == tenant_id)) + await db.execute(delete(Dept).where(Dept.tenant_id == tenant_id)) @staticmethod - async def replace_role_menus(db: AsyncSession, *, role_id: int, tenant_id: int, menu_ids: list[int]) -> None: + async def replace_role_menus(db: AsyncSession, role_id: int, tenant_id: int, menu_ids: list[int]) -> None: """ 重建角色菜单关联 @@ -178,7 +231,13 @@ async def replace_role_menus(db: AsyncSession, *, role_id: int, tenant_id: int, [{'role_id': role_id, 'menu_id': menu_id, 'tenant_id': tenant_id} for menu_id in menu_ids], ) - async def sync_admin_role_menus(self, db: AsyncSession, *, tenant_id: int, role_name: str, menu_ids: list[int]) -> bool: + async def sync_admin_role_menus( + self, + db: AsyncSession, + tenant_id: int, + role_name: str, + menu_ids: list[int], + ) -> bool: """ 同步租户管理员角色菜单 @@ -196,7 +255,7 @@ async def sync_admin_role_menus(self, db: AsyncSession, *, tenant_id: int, role_ if not admin_role: return False - await self.replace_role_menus(db, role_id=admin_role.id, tenant_id=tenant_id, menu_ids=menu_ids) + await self.replace_role_menus(db, admin_role.id, tenant_id, menu_ids) await user_cache_manager.clear_by_role_id(db, [admin_role.id]) return True finally: @@ -205,7 +264,6 @@ async def sync_admin_role_menus(self, db: AsyncSession, *, tenant_id: int, role_ async def init_related_data( self, db: AsyncSession, - *, tenant: Tenant, admin_username: str, admin_password: str, @@ -242,7 +300,7 @@ async def init_related_data( db.add(role) await db.flush() - await self.replace_role_menus(db, role_id=role.id, tenant_id=tenant.id, menu_ids=menu_ids) + await self.replace_role_menus(db, role.id, tenant.id, menu_ids) salt = bcrypt.gensalt() hashed_password = get_hash_password(admin_password, salt) @@ -272,57 +330,5 @@ async def init_related_data( .values(admin_user_id=admin_user.id, admin_username=admin_username) ) - @staticmethod - async def update_admin_password(db: AsyncSession, *, user_id: int, password: str) -> None: - """ - 更新租户管理员密码 - - :param db: 数据库会话 - :param user_id: 用户 ID - :param password: 密码 - :return: - """ - salt = bcrypt.gensalt() - hashed_password = get_hash_password(password, salt) - await db.execute(update(User).where(User.id == user_id).values(password=hashed_password, salt=salt)) - - @staticmethod - async def delete_related_data(db: AsyncSession, *, tenant_id: int) -> None: - """ - 删除租户关联数据 - - :param db: 数据库会话 - :param tenant_id: 租户 ID - :return: - """ - await db.execute(delete(user_role).where(user_role.c.tenant_id == tenant_id)) - await db.execute(delete(role_menu).where(role_menu.c.tenant_id == tenant_id)) - await db.execute(delete(role_data_scope).where(role_data_scope.c.tenant_id == tenant_id)) - await db.execute(delete(UserPasswordHistory).where(UserPasswordHistory.tenant_id == tenant_id)) - await db.execute(delete(OperaLog).where(OperaLog.tenant_id == tenant_id)) - await db.execute(delete(LoginLog).where(LoginLog.tenant_id == tenant_id)) - - try: - from backend.plugin.oauth2.model.user_social import UserSocial - - user_stmt = select(User.id).where(User.tenant_id == tenant_id) - user_result = await db.execute(user_stmt) - user_ids = [row[0] for row in user_result.all()] - if user_ids: - await db.execute(delete(UserSocial).where(UserSocial.user_id.in_(user_ids))) - except ImportError: - pass - - try: - from backend.plugin.notice.model.notice import Notice - - await db.execute(delete(Notice).where(Notice.tenant_id == tenant_id)) - except ImportError: - pass - - await db.execute(delete(User).where(User.tenant_id == tenant_id)) - await db.execute(delete(Role).where(Role.tenant_id == tenant_id)) - await db.execute(delete(Dept).where(Dept.tenant_id == tenant_id)) - tenant_dao: CRUDTenant = CRUDTenant(Tenant) diff --git a/service/package_service.py b/service/package_service.py index fb87673..085efbd 100644 --- a/service/package_service.py +++ b/service/package_service.py @@ -1,6 +1,5 @@ from typing import Any -from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from backend.common.exception import errors @@ -11,7 +10,6 @@ from backend.plugin.tenant.model import TenantPackage from backend.plugin.tenant.schema.package import ( CreateTenantPackageParam, - GetTenantPackageDetail, UpdateTenantPackageParam, ) from backend.utils.build_tree import get_tree_data @@ -75,9 +73,9 @@ async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantPackageParam) -> for tenant_id in tenant_ids: await tenant_dao.sync_admin_role_menus( db, - tenant_id=tenant_id, - role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, - menu_ids=obj.menus, + tenant_id, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + obj.menus, ) return count or 1 diff --git a/service/tenant_service.py b/service/tenant_service.py index f1d17bf..9b1fb10 100644 --- a/service/tenant_service.py +++ b/service/tenant_service.py @@ -1,12 +1,8 @@ from typing import Any -import bcrypt - from fast_captcha import text_captcha -from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.admin.utils.password_security import get_hash_password from backend.common.exception import errors from backend.common.pagination import paging_data from backend.core.conf import settings @@ -87,11 +83,11 @@ async def create(*, db: AsyncSession, obj: CreateTenantParam) -> None: menu_ids = await tenant_package_dao.get_menu_ids(db, tenant.package_id) await tenant_dao.init_related_data( db, - tenant=tenant, - admin_username=obj.admin_username, - admin_password=obj.admin_password, - role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, - menu_ids=menu_ids, + tenant, + obj.admin_username, + obj.admin_password, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids, ) @staticmethod @@ -120,9 +116,9 @@ async def update(*, db: AsyncSession, pk: int, obj: UpdateTenantParam) -> int: menu_ids = await tenant_package_dao.get_menu_ids(db, obj.package_id) await tenant_dao.sync_admin_role_menus( db, - tenant_id=pk, - role_name=settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, - menu_ids=menu_ids, + pk, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids, ) return await tenant_dao.update(db, pk, obj) @@ -138,9 +134,7 @@ async def update_admin_password(*, db: AsyncSession, pk: int, password: str) -> if not tenant.admin_user_id: raise errors.NotFoundError(msg='租户管理员用户不存在') - await tenant_dao.update_admin_password( - db, user_id=tenant.admin_user_id, password=password - ) + await tenant_dao.update_admin_password(db, tenant.admin_user_id, password) @staticmethod async def delete(*, db: AsyncSession, obj: DeleteTenantParam) -> int: @@ -149,7 +143,7 @@ async def delete(*, db: AsyncSession, obj: DeleteTenantParam) -> int: if not tenant: continue - await tenant_dao.delete_related_data(db, tenant_id=pk) + await tenant_dao.delete_related_data(db, pk) return await tenant_dao.delete(db, obj.pks) From 8ef85e8e5e4a5d313257dd587a9fdcc4b80febe3 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sun, 15 Mar 2026 19:12:02 +0800 Subject: [PATCH 8/8] Fix destroy SQL scripts --- sql/mysql/destroy.sql | 5 +++++ sql/mysql/destroy_snowflake.sql | 5 +++++ sql/postgresql/destroy.sql | 5 +++++ sql/postgresql/destroy_snowflake.sql | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/sql/mysql/destroy.sql b/sql/mysql/destroy.sql index 9de173c..88b10b7 100644 --- a/sql/mysql/destroy.sql +++ b/sql/mysql/destroy.sql @@ -1,5 +1,10 @@ delete from sys_user_role where tenant_id > 0; delete from sys_role_menu where tenant_id > 0; +delete from sys_role_data_scope where tenant_id > 0; +delete from sys_user_password_history where tenant_id > 0; +delete from sys_opera_log where tenant_id > 0; +delete from sys_login_log where tenant_id > 0; +delete from sys_notice where tenant_id > 0; delete from sys_user where tenant_id > 0; delete from sys_role where tenant_id > 0; delete from sys_dept where tenant_id > 0; diff --git a/sql/mysql/destroy_snowflake.sql b/sql/mysql/destroy_snowflake.sql index 9de173c..88b10b7 100644 --- a/sql/mysql/destroy_snowflake.sql +++ b/sql/mysql/destroy_snowflake.sql @@ -1,5 +1,10 @@ delete from sys_user_role where tenant_id > 0; delete from sys_role_menu where tenant_id > 0; +delete from sys_role_data_scope where tenant_id > 0; +delete from sys_user_password_history where tenant_id > 0; +delete from sys_opera_log where tenant_id > 0; +delete from sys_login_log where tenant_id > 0; +delete from sys_notice where tenant_id > 0; delete from sys_user where tenant_id > 0; delete from sys_role where tenant_id > 0; delete from sys_dept where tenant_id > 0; diff --git a/sql/postgresql/destroy.sql b/sql/postgresql/destroy.sql index 88b9a82..9cecb72 100644 --- a/sql/postgresql/destroy.sql +++ b/sql/postgresql/destroy.sql @@ -1,5 +1,10 @@ delete from sys_user_role where tenant_id > 0; delete from sys_role_menu where tenant_id > 0; +delete from sys_role_data_scope where tenant_id > 0; +delete from sys_user_password_history where tenant_id > 0; +delete from sys_opera_log where tenant_id > 0; +delete from sys_login_log where tenant_id > 0; +delete from sys_notice where tenant_id > 0; delete from sys_user where tenant_id > 0; delete from sys_role where tenant_id > 0; delete from sys_dept where tenant_id > 0; diff --git a/sql/postgresql/destroy_snowflake.sql b/sql/postgresql/destroy_snowflake.sql index 9de173c..88b10b7 100644 --- a/sql/postgresql/destroy_snowflake.sql +++ b/sql/postgresql/destroy_snowflake.sql @@ -1,5 +1,10 @@ delete from sys_user_role where tenant_id > 0; delete from sys_role_menu where tenant_id > 0; +delete from sys_role_data_scope where tenant_id > 0; +delete from sys_user_password_history where tenant_id > 0; +delete from sys_opera_log where tenant_id > 0; +delete from sys_login_log where tenant_id > 0; +delete from sys_notice where tenant_id > 0; delete from sys_user where tenant_id > 0; delete from sys_role where tenant_id > 0; delete from sys_dept where tenant_id > 0;