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 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..ba8a77b --- /dev/null +++ b/api/v1/package.py @@ -0,0 +1,99 @@ +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 +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.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]]: + page_data = await tenant_package_service.get_list(db=db, name=name, status=status) + 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..9510b5d --- /dev/null +++ b/api/v1/tenant.py @@ -0,0 +1,132 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +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.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]]: + page_data = await tenant_service.get_list( + db=db, + name=name, + code=code, + domain=domain, + package_id=package_id, + status=status, + ) + 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..0073699 --- /dev/null +++ b/crud/crud_package.py @@ -0,0 +1,166 @@ +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 = self.model(**dict_obj) + db.add(new_package) + 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'}) + 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..3e55554 --- /dev/null +++ b/crud/crud_tenant.py @@ -0,0 +1,334 @@ +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 + +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.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 +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_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, + *, + 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 = 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: + """ + 更新租户 + + :param db: 数据库会话 + :param pk: 租户 ID + :param obj: 更新租户参数 + :return: + """ + return await self.update_model(db, pk, obj) + + @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)) + + async def delete(self, db: AsyncSession, pks: list[int]) -> int: + """ + 批量删除租户 + + :param db: 数据库会话 + :param pks: 租户 ID 列表 + :return: + """ + return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks) + + @staticmethod + async def delete_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)) + + @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], + ) + + async def sync_admin_role_menus( + self, + 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: + """ + 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 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: + ctx.tenant_id = current_tenant_id + + async def init_related_data( + self, + db: AsyncSession, + tenant: Tenant, + admin_username: str, + admin_password: str, + role_name: str, + menu_ids: list[int], + ) -> None: + """ + 初始化租户关联数据 + + :param db: 数据库会话 + :param tenant: 租户对象 + :param admin_username: 管理员用户名 + :param admin_password: 管理员密码 + :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 self.replace_role_menus(db, role.id, tenant.id, menu_ids) + + salt = bcrypt.gensalt() + hashed_password = get_hash_password(admin_password, salt) + 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) + ) + + +tenant_dao: CRUDTenant = CRUDTenant(Tenant) diff --git a/listener.py b/listener.py new file mode 100644 index 0000000..b2e3a3d --- /dev/null +++ b/listener.py @@ -0,0 +1,29 @@ +from sqlalchemy import event +from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria + +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 + 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 + """为新增模型自动注入当前租户 ID""" + 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..75af5bd --- /dev/null +++ b/model/tenant.py @@ -0,0 +1,27 @@ +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' + __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, 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..9674c00 --- /dev/null +++ b/plugin.toml @@ -0,0 +1,13 @@ +[plugin] +summary = "多租户" +version = "0.0.1" +description = "为系统提供多租户能力,包括租户管理、套餐管理、行级数据隔离" +author = "wu-clan" +tags = ["other"] +database = ["mysql", "postgresql"] + +[app] +router = ["v1"] + +[settings] +TENANT_ADMIN_DEFAULT_ROLE_NAME = "租户管理员" 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..65846fc --- /dev/null +++ b/schema/package.py @@ -0,0 +1,39 @@ +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(TenantPackageSchemaBase): + """更新租户套餐参数""" + + menus: list[int] = Field(description='菜单 ID 列表') + + +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_factory=list, description='关联的菜单 ID 列表') diff --git a/schema/tenant.py b/schema/tenant.py new file mode 100644 index 0000000..414556f --- /dev/null +++ b/schema/tenant.py @@ -0,0 +1,55 @@ +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(TenantSchemaBase): + """更新租户参数""" + + +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..085efbd --- /dev/null +++ b/service/package_service.py @@ -0,0 +1,93 @@ +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +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, + UpdateTenantPackageParam, +) +from backend.utils.build_tree import get_tree_data + + +class TenantPackageService: + @staticmethod + async def get(*, db: AsyncSession, pk: int) -> TenantPackage: + package = await tenant_package_dao.get(db, pk) + if not package: + raise errors.NotFoundError(msg='套餐不存在') + return package + + @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 != 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) + 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, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + obj.menus, + ) + + return count or 1 + + @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..9b1fb10 --- /dev/null +++ b/service/tenant_service.py @@ -0,0 +1,151 @@ +from typing import Any + +from fast_captcha import text_captcha +from sqlalchemy.ext.asyncio import AsyncSession + +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_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='租户域名已存在') + + 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='套餐不存在') + 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) + menu_ids = await tenant_package_dao.get_menu_ids(db, tenant.package_id) + await tenant_dao.init_related_data( + db, + tenant, + obj.admin_username, + obj.admin_password, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + menu_ids, + ) + + @staticmethod + 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 != tenant.name: + existing = await tenant_dao.get_by_name(db, obj.name) + if existing: + raise errors.ForbiddenError(msg='租户名称已存在') + + 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 != 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='租户套餐已被禁用') + + menu_ids = await tenant_package_dao.get_menu_ids(db, obj.package_id) + await tenant_dao.sync_admin_role_menus( + db, + pk, + settings.TENANT_ADMIN_DEFAULT_ROLE_NAME, + 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='租户不存在') + if not tenant.admin_user_id: + raise errors.NotFoundError(msg='租户管理员用户不存在') + + await tenant_dao.update_admin_password(db, tenant.admin_user_id, password) + + @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 tenant_dao.delete_related_data(db, 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..88b10b7 --- /dev/null +++ b/sql/mysql/destroy.sql @@ -0,0 +1,18 @@ +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; + +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..88b10b7 --- /dev/null +++ b/sql/mysql/destroy_snowflake.sql @@ -0,0 +1,18 @@ +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; + +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..9cecb72 --- /dev/null +++ b/sql/postgresql/destroy.sql @@ -0,0 +1,20 @@ +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; + +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..88b10b7 --- /dev/null +++ b/sql/postgresql/destroy_snowflake.sql @@ -0,0 +1,18 @@ +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; + +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); 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