diff --git a/__init__.py b/__init__.py index 0b3c8e8..0f38586 100644 --- a/__init__.py +++ b/__init__.py @@ -221,6 +221,7 @@ _LAZY_SYMBOLS = { 'PositionManager': ('auth', 'PositionManager'), 'AuthManager': ('auth', 'AuthManager'), 'AdminUserManager': ('auth', 'AdminUserManager'), + 'TenantManager': ('auth', 'TenantManager'), 'UserService': ('auth', 'UserService'), # ── 分佣引擎 ────────────────────────────────────────── diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc index 048f1c4..fb44b4d 100644 Binary files a/__pycache__/__init__.cpython-312.pyc and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 37041c8..7f6eef7 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/auth/__init__.py b/auth/__init__.py index 5111673..3e4e233 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -6,7 +6,7 @@ __all__ = [ 'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', 'IsAdminScopeDisabled', 'IsTenantScopeDisabled', 'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin', - 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'UserService', + 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'UserService', ] @@ -30,7 +30,7 @@ def __getattr__(name): val = getattr(_m, name) globals()[name] = val return val - if name in ('RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager'): + if name in ('RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager'): from gvsdsdk.auth import managers as _m val = getattr(_m, name) globals()[name] = val diff --git a/auth/__pycache__/__init__.cpython-312.pyc b/auth/__pycache__/__init__.cpython-312.pyc index 26fefb6..2acee75 100644 Binary files a/auth/__pycache__/__init__.cpython-312.pyc and b/auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/auth/__pycache__/managers.cpython-312.pyc b/auth/__pycache__/managers.cpython-312.pyc index d9155b5..18d2d4a 100644 Binary files a/auth/__pycache__/managers.cpython-312.pyc and b/auth/__pycache__/managers.cpython-312.pyc differ diff --git a/auth/managers.py b/auth/managers.py index 8a88c63..9fc4bab 100644 --- a/auth/managers.py +++ b/auth/managers.py @@ -1,7 +1,7 @@ import uuid from django.utils import timezone as _timezone -from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password +from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant from gvsdsdk.auth.crypto import HashPassword, VerifyPassword from gvsdsdk.model_utils import _GetUserModel, UUIDObj @@ -339,6 +339,107 @@ class RoleManager: pass return user_uuids, role_uuids + @staticmethod + def GetPermissionByCode(PermCode, TenantUUID=None): + """按权限码查询启用权限(存在多个取首个;不存在返回 None)""" + if not PermCode: + return None + qs = Permission.objects.filter(PermCode=PermCode, PermStatus=1) + if TenantUUID is not None: + qs = qs.filter(TenantUUID=TenantUUID) + return qs.first() + + @staticmethod + def HasUserPermission(UserUUID, PermCode, CompanyUUID=None): + """检查用户是否拥有指定权限码(UserRole→RolePermission→Permission 链路) + + Args: + UserUUID: 用户 UUID + PermCode: 权限码(如 'Order:update') + CompanyUUID: 可选,限定公司范围(默认按用户角色关联的公司集合过滤, + 与 core.User.HasPermission 原逻辑一致) + + Returns: + bool + """ + if not PermCode or not UserUUID: + return False + try: + user_bytes = UUIDObj(UserUUID).bytes + except Exception: + user_bytes = UserUUID + perm_uuids = set( + Permission.objects.filter(PermCode=PermCode, PermStatus=1) + .values_list('PermUUID', flat=True) + ) + if not perm_uuids: + return False + user_roles = UserRole.objects.filter(UserUUID=user_bytes) + user_role_uuids = user_roles.values_list('RoleUUID', flat=True) + if not user_role_uuids: + return False + rp_qs = RolePermission.objects.filter( + RoleUUID__in=user_role_uuids, + PermUUID__in=perm_uuids, + ) + if CompanyUUID is not None: + rp_qs = rp_qs.filter(CompanyUUID=CompanyUUID) + else: + # 默认按用户角色关联的公司集合过滤(与 core.User.HasPermission 一致) + company_uuids = list(set(user_roles.values_list('CompanyUUID', flat=True))) + if company_uuids: + rp_qs = rp_qs.filter(CompanyUUID__in=company_uuids) + return rp_qs.exists() + + @staticmethod + def CheckTablePermission(RoleUUID, PermCode, CompanyUUID=None, TableName=None): + """检查角色对某表某动作权限码的 RBAC 判定 + + 组合判定:权限码是否定义 + 角色是否拥有 + 角色是否参与该表授权。 + + Args: + RoleUUID: 角色 UUID(可为 None,视为未参与授权) + PermCode: 完整权限码(如 'Order:update') + CompanyUUID: 可选,公司 UUID + TableName: 表名(用于"参与授权"回退判定,如 'Order') + + Returns: + (Granted, Decided) 元组: + - (True, True): 已授权,放行 + - (False, True): 明确拒绝(角色参与该表授权但缺此动作权限) + - (None, False): 权限码未定义或角色未参与该表授权,回退调用方原有逻辑 + """ + # 1. 权限码是否在系统中定义 + perm_qs = Permission.objects.filter(PermCode=PermCode, PermStatus=1) + if not perm_qs.exists(): + return None, False + perm_uuids = set(perm_qs.values_list('PermUUID', flat=True)) + if not RoleUUID: + return None, False + # 2. 角色是否拥有该权限 + rp_qs = RolePermission.objects.filter(RoleUUID=RoleUUID, PermUUID__in=perm_uuids) + if CompanyUUID is not None: + rp_qs = rp_qs.filter(CompanyUUID=CompanyUUID) + if rp_qs.exists(): + return True, True + # 3. 角色是否参与该表授权(拥有该表的其他权限码) + if not TableName: + return False, True + role_perm_uuids = set( + RolePermission.objects.filter(RoleUUID=RoleUUID) + .values_list('PermUUID', flat=True) + ) + if not role_perm_uuids: + return None, False + has_table_perm = Permission.objects.filter( + PermUUID__in=role_perm_uuids, + PermCode__startswith=f'{TableName}:', + PermStatus=1, + ).exists() + if not has_table_perm: + return None, False + return False, True + class PositionManager: """职务管理器——提供人事职务标签创建、岗位模板管理""" @@ -761,6 +862,137 @@ class AuthManager: avatar.save(update_fields=['UserAvatar', 'AvatarFileType', 'AvatarUploadTime']) return avatar + @staticmethod + def VerifyUserPassword(UserUUID, Password): + """校验用户密码(独立 Password 审计表) + + Returns: + (用户记录存在?, 密码是否匹配) + """ + return AdminUserManager.VerifyPassword(UserUUID, Password) + + @staticmethod + def Setup2FA(UserUUID, Password): + """发起 2FA 设置——校验密码后生成密钥写入待验证表 + + Args: + UserUUID: 用户 UUID + Password: 明文密码(校验 Password 审计表) + + Returns: + (成功?, 消息, secret 或 None) + """ + found, valid = AuthManager.VerifyUserPassword(UserUUID, Password) + if not found: + return False, '密码验证失败', None + if not valid: + return False, '密码错误', None + if User2FA.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).exists(): + return False, '已启用2FA', None + import pyotp + secret = pyotp.random_base32() + UserPending2FA.objects.update_or_create( + UserUUID=UUIDObj(UserUUID).bytes, + defaults={'SecretKey': secret}, + ) + return True, '请扫描二维码', secret + + @staticmethod + def GetPending2FA(UserUUID): + """获取用户待验证 2FA 记录(不存在返回 None)""" + return UserPending2FA.objects.filter( + UserUUID=UUIDObj(UserUUID).bytes, + ).first() + + @staticmethod + def Disable2FA(UserUUID, Password, Token): + """禁用 2FA——校验密码 + TOTP 验证码后删除启用记录 + + Returns: + (成功?, 消息) + """ + found, valid = AuthManager.VerifyUserPassword(UserUUID, Password) + if not found: + return False, '密码验证失败' + if not valid: + return False, '密码错误' + two_fa = User2FA.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).first() + if two_fa is None: + return False, '未启用2FA' + if not AuthManager.Verify2FA(UserUUID, Token): + return False, '验证码错误' + two_fa.delete() + return True, '2FA已禁用' + + @staticmethod + def SendAccountActivationCode(UserUUID, Email): + """发送邮箱激活码——删除旧码并生成新 6 位码 + + Returns: + (成功?, 消息, code 或 None) + """ + if not Email: + return False, '邮箱不能为空', None + user = AuthManager.GetUserByUUID(UserUUID) + if user is None: + return False, '用户不存在', None + if user.UserEmail and user.UserEmail == Email: + return True, '邮箱已验证', None + code = ''.join([str(uuid.uuid4().int % 10) for _ in range(6)]) + AccountActivation.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).delete() + AccountActivation.objects.create( + AccountActivationCode=code.encode('utf-8'), + UserUUID=UUIDObj(UserUUID).bytes, + ) + return True, '验证码已发送', code + + @staticmethod + def VerifyAccountActivationCode(UserUUID, Code, Email, ExpireMinutes=30): + """校验邮箱激活码——校验码值 + 过期时间,成功后更新邮箱并置已认证 + + Returns: + (成功?, 消息) + """ + if not Code or not Email: + return False, '验证码或邮箱不能为空' + activation = AccountActivation.objects.filter( + UserUUID=UUIDObj(UserUUID).bytes, + ).first() + if activation is None: + return False, '请先发送验证码' + stored_code = activation.AccountActivationCode + if isinstance(stored_code, memoryview): + stored_code = bytes(stored_code) + if stored_code.decode('utf-8') != Code: + return False, '验证码错误' + from datetime import timedelta + created = activation.CreateTime + if created and _timezone.now() - created > timedelta(minutes=ExpireMinutes): + activation.delete() + return False, '验证码已过期' + activation.delete() + user = AuthManager.GetUserByUUID(UserUUID) + if user is None: + return False, '用户不存在' + user.UserEmail = Email + user.UserEmailVerify = 1 + user.save(update_fields=['UserEmail', 'UserEmailVerify']) + return True, '邮箱验证成功' + + @staticmethod + def ChangeUserEmail(UserUUID, NewEmail): + """修改用户邮箱(仅更新 UserEmail 字段,不重置认证状态) + + Returns: + (成功?, 消息) + """ + user = AuthManager.GetUserByUUID(UserUUID) + if user is None: + return False, '用户不存在' + user.UserEmail = NewEmail + user.save(update_fields=['UserEmail']) + return True, '邮箱修改成功' + class AdminUserManager: """管理端用户管理器——用户搜索、密码重置、账户状态与邮箱管理""" @@ -876,3 +1108,94 @@ class AdminUserManager: 'CreateTime': two_fa.CreateTime.isoformat() if two_fa.CreateTime else None, } + +class TenantManager: + """租户管理器——租户入驻申请与审核""" + + # 审核状态常量 + REVIEW_PENDING = 0 + REVIEW_APPROVED = 1 + REVIEW_REJECTED = 2 + + @staticmethod + def ApplyTenant(TenantName, TenantExpireTime, ReviewRemark=''): + """提交租户入驻申请(创建待审核租户) + + Args: + TenantName: 租户名称 + TenantExpireTime: 到期时间(datetime) + ReviewRemark: 审核备注 + + Returns: + (Tenant 实例, 错误消息 or None);租户名已存在或入参缺失返回 (None, 错误) + """ + if not TenantName: + return None, '租户名称不能为空' + if TenantExpireTime is None: + return None, '到期时间不能为空' + if Tenant.objects.filter(TenantName=TenantName).exists(): + return None, f'租户名称「{TenantName}」已存在' + tenant = Tenant.objects.create( + TenantUUID=uuid.uuid4().bytes, + TenantName=TenantName, + TenantStatus=1, + TenantExpireTime=TenantExpireTime, + ReviewStatus=TenantManager.REVIEW_PENDING, + ReviewRemark=ReviewRemark, + ) + return tenant, None + + @staticmethod + def ListPendingTenants(): + """获取待审核租户列表(按创建时间倒序)""" + return Tenant.objects.filter( + ReviewStatus=TenantManager.REVIEW_PENDING, + ).order_by('-CreateTime') + + @staticmethod + def GetTenantByUUID(TenantUUID): + """按 UUID 查询租户(不存在返回 None)""" + try: + return Tenant.objects.get(TenantUUID=UUIDObj(TenantUUID).bytes) + except (Tenant.DoesNotExist, ValueError, AttributeError): + return None + + @staticmethod + def ApproveTenant(TenantUUID, ReviewerUUID): + """批准租户入驻(状态 0→1,记录审核人与时间) + + Returns: + (Tenant 实例, 错误消息 or None);租户不存在或状态非待审核返回 (None, 错误) + """ + tenant = TenantManager.GetTenantByUUID(TenantUUID) + if tenant is None: + return None, '租户不存在' + if tenant.ReviewStatus != TenantManager.REVIEW_PENDING: + return None, f'租户当前状态非待审核(状态码:{tenant.ReviewStatus})' + tenant.ReviewStatus = TenantManager.REVIEW_APPROVED + tenant.ReviewerUUID = ReviewerUUID + tenant.ReviewTime = _timezone.now() + tenant.save(update_fields=['ReviewStatus', 'ReviewerUUID', 'ReviewTime']) + return tenant, None + + @staticmethod + def RejectTenant(TenantUUID, ReviewerUUID, RejectReason): + """拒绝租户入驻(状态 0→2,记录理由/审核人/时间) + + Returns: + (Tenant 实例, 错误消息 or None) + """ + if not RejectReason: + return None, '拒绝理由不能为空' + tenant = TenantManager.GetTenantByUUID(TenantUUID) + if tenant is None: + return None, '租户不存在' + if tenant.ReviewStatus != TenantManager.REVIEW_PENDING: + return None, f'租户当前状态非待审核(状态码:{tenant.ReviewStatus})' + tenant.ReviewStatus = TenantManager.REVIEW_REJECTED + tenant.ReviewRemark = RejectReason + tenant.ReviewerUUID = ReviewerUUID + tenant.ReviewTime = _timezone.now() + tenant.save(update_fields=['ReviewStatus', 'ReviewRemark', 'ReviewerUUID', 'ReviewTime']) + return tenant, None + diff --git a/models.py b/models.py index 5fd6ad0..caf4a7a 100644 --- a/models.py +++ b/models.py @@ -446,6 +446,32 @@ class AccountActivation(QModel): verbose_name_plural = '账户激活' +# === 租户表 === + +class Tenant(QModel): + """租户表——多租户架构核心(映射 SAAS 主库 Tenant 表)""" + + TenantUUID = UUIDField(primary_key=True, db_column='TenantUUID', verbose_name='租户UUID') + TenantName = models.CharField(max_length=256, db_column='TenantName', verbose_name='租户名称') + TenantStatus = models.SmallIntegerField(default=1, db_column='TenantStatus', verbose_name='租户状态') + TenantExpireTime = models.DateTimeField(db_column='TenantExpireTime', verbose_name='到期时间') + ReviewStatus = models.SmallIntegerField(default=0, db_column='ReviewStatus', verbose_name='审核状态') + ReviewRemark = models.CharField(max_length=512, default='', db_column='ReviewRemark', verbose_name='审核备注') + ReviewerUUID = UUIDField(null=True, blank=True, db_column='ReviewerUUID', verbose_name='审核人UUID') + ReviewTime = models.DateTimeField(null=True, blank=True, db_column='ReviewTime', verbose_name='审核时间') + CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'Tenant' + verbose_name = '租户' + verbose_name_plural = '租户' + + def __str__(self): + return self.TenantName + + # === 角色表 === class Role(QModel):