diff --git a/gvsds/apps/commission/strategies/__init__.py b/gvsds/apps/commission/strategies/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/gvsds/apps/commission/strategies/engine.py b/gvsds/apps/commission/strategies/engine.py deleted file mode 100644 index 824f9b31..00000000 --- a/gvsds/apps/commission/strategies/engine.py +++ /dev/null @@ -1,156 +0,0 @@ -"""模块:分佣策略引擎 -策略模式实现——每种分账方式独立封装,新增分账逻辑只需添加新策略类。 - -支持四种策略: - PercentageStrategy: 比例分账 → TotalAmount × rate - FixedStrategy: 固定金额 → 固定值(受保底/封顶约束) - TieredStrategy: 阶梯分账 → 按次数/档位查表 - RemainderStrategy: 剩余归平台 → TotalAmount - SUM(其他参与者) -""" - -from abc import ABC, abstractmethod -from decimal import Decimal -from typing import Dict, List, Optional -from gvsds.apps.commission.models import RateTable - - -class BaseSplitStrategy(ABC): - """分账策略抽象基类""" - - def __init__(self, participant_rule: dict, rate_table: Optional[RateTable] = None): - self._rule = participant_rule - self._rate_table = rate_table - - @abstractmethod - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - ... - - @property - def Role(self): - return self._rule.get('Role', 'unknown') - - @property - def SplitType(self): - return self._rule.get('Type', 'unknown') - - def _get_rate_from_table(self, key): - if self._rate_table: - rate = self._rate_table.GetRate(key) - if rate is not None: - return rate - return self._rule.get('Rate', Decimal('0')) - - -class PercentageStrategy(BaseSplitStrategy): - """比例分账策略 - - 配置示例: - { role: 'worker', type: 'percentage', value: 0.80 } - { role: 'worker', type: 'percentage', rate_table_key: '1' } - """ - - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - rate_key = self._rule.get('RateTableKey', '') - if rate_key: - rate = self._get_rate_from_table(rate_key) - else: - rate = Decimal(str(self._rule.get('Value', 0))) - amount = total_amount * rate - return self._apply_bounds(amount, total_amount) - - def _apply_bounds(self, amount, total_amount): - minimum = self._rule.get('MinAmount') - maximum = self._rule.get('MaxAmount') - if minimum is not None: - amount = max(amount, Decimal(str(minimum))) - if maximum is not None: - amount = min(amount, Decimal(str(maximum))) - return amount - - -class FixedStrategy(BaseSplitStrategy): - """固定金额策略 - - 配置示例: - { role: 'agent', type: 'fixed', value: 10 } - { role: 'leader', type: 'fixed', rate_table_key: 'guanshi_fc' } - """ - - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - rate_key = self._rule.get('RateTableKey', '') - if rate_key: - amount = self._get_rate_from_table(rate_key) - else: - amount = Decimal(str(self._rule.get('Value', 0))) - return amount - - -class TieredStrategy(BaseSplitStrategy): - """阶梯分账策略——按次数/档位查表 - - 配置示例: - { role: 'agent', type: 'tiered', - tiers: { 1: 5, 2: 8, 3: 10 } } - 或从费率表中按 key_N 形式查找。 - """ - - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - tiers = self._rule.get('Tiers', {}) - if tiers: - tier_key = str(tier_index) - raw_value = tiers.get(tier_key, tiers.get('default', 0)) - if isinstance(raw_value, str) and raw_value.endswith('%'): - return total_amount * Decimal(raw_value.rstrip('%')) / Decimal('100') - return Decimal(str(raw_value)) - - tier_key = self._rule.get('TierKeyPrefix', '') - if tier_key and self._rate_table: - rate = self._rate_table.GetRate(f'{tier_key}_{tier_index}') - if rate is not None: - return rate - - return Decimal('0') - - -class RemainderStrategy(BaseSplitStrategy): - """剩余归平台策略——扣除所有其他参与者后的余额 - - 配置示例: - { role: 'platform', type: 'remainder' } - """ - - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - allocated = context.get('AllocatedAmount', Decimal('0')) - remainder = total_amount - allocated - return remainder if remainder > 0 else Decimal('0') - - -class WithdrawalFeeStrategy(BaseSplitStrategy): - """提现手续费策略 - - 配置示例: - { role: 'platform', type: 'withdrawal_fee', value: 0.01 } - """ - - def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal: - rate_key = self._rule.get('RateTableKey', '') - if rate_key: - rate = self._get_rate_from_table(rate_key) - else: - rate = Decimal(str(self._rule.get('Value', 0))) - return total_amount * rate - - -STRATEGY_MAP: Dict[str, type] = { - 'percentage': PercentageStrategy, - 'fixed': FixedStrategy, - 'tiered': TieredStrategy, - 'remainder': RemainderStrategy, - 'withdrawal_fee': WithdrawalFeeStrategy, -} - - -def CreateStrategy(participant_rule: dict, rate_table: Optional[RateTable] = None) -> BaseSplitStrategy: - split_type = participant_rule.get('Type', 'percentage') - strategy_class = STRATEGY_MAP.get(split_type, PercentageStrategy) - return strategy_class(participant_rule, rate_table) \ No newline at end of file diff --git a/gvsds/apps/crb/business.py b/gvsds/apps/crb/business.py index 4ba1d44e..2becc8a1 100644 --- a/gvsds/apps/crb/business.py +++ b/gvsds/apps/crb/business.py @@ -7,8 +7,8 @@ Tenant/Company)。业务规则移植自旧 Flask 实现: - ``GetGVSDSUserToMCPlayerUUID``:委托 ``gvsds.apps.mc.manager.EasyAuthManager`` 反查 easyauth 表的 UserRoleUUID 绑定标记,把 GVSDS UserUUID 映射为 MC 玩家 UUID 字符串(与 MC 绑定流程保持一致) -- ``GetUserByUUID`` / ``GetUserByName``:通过业务子类的 User 模型按 - UUID 或用户名查询,返回 ``User`` 实例 +- ``GetUserByUUID`` / ``GetUserByName``:委托 ``gvsdsdk.auth.managers.AuthManager`` + 按 UUID 或用户名查询,返回 ``User`` 实例 - ``GetUserNameByUUID``:返回 ``User.UserName``,找不到返回 ``None`` - ``RequireGCBalance``:检查当前用户是否绑定了 MC 钱包且有 GC 币种余额, 返回 ``(CmeBalances | None, error_response | None)`` 二元组 @@ -17,8 +17,6 @@ Tenant/Company)。业务规则移植自旧 Flask 实现: import uuid as _uuid from typing import Optional, Tuple -from gvsdsdk.model_utils import UUIDObj, _GetUserModel - from .models import CmeBalances, CmeCurrencies @@ -101,20 +99,25 @@ def GetGVSDSUserByPlayerName(player_name: str): def GetUserByUUID(user_uuid): - """按 UserUUID 查询业务 User 实例(找不到返回 None)""" + """按 UserUUID 查询业务 User 实例(找不到返回 None) + + 委托 ``gvsdsdk.auth.managers.AuthManager.GetUserByUUID``,保持单一用户查询入口。 + """ if not user_uuid: return None - User = _GetUserModel() - raw_bytes = UUIDObj(user_uuid).bytes - return User.objects.filter(UserUUID=raw_bytes).first() + from gvsdsdk.auth.managers import AuthManager + return AuthManager.GetUserByUUID(user_uuid) def GetUserByName(username: str): - """按 UserName 查询业务 User 实例(找不到返回 None)""" + """按 UserName 查询业务 User 实例(找不到返回 None) + + 委托 ``gvsdsdk.auth.managers.AuthManager.GetUserByUserName``。 + """ if not username: return None - User = _GetUserModel() - return User.objects.filter(UserName=username).first() + from gvsdsdk.auth.managers import AuthManager + return AuthManager.GetUserByUserName(username) def GetUserNameByUUID(user_uuid) -> Optional[str]: diff --git a/gvsds/apps/crb/views.py b/gvsds/apps/crb/views.py index b7e8b05b..05f9b927 100644 --- a/gvsds/apps/crb/views.py +++ b/gvsds/apps/crb/views.py @@ -112,13 +112,13 @@ def _MenuLinkCompanyUUID(page_path: str): """根据页面路径查菜单所属公司 UUID bytes(缓存,避免 N+1) 返回 (company_uuid_bytes, is_private);is_private=False 表示公开菜单不拦截。 """ - from gvsds.apps.gerp.models import Menu + from gvsdsdk.auth.managers import MenuManager if not page_path: return None, False cached = _MENU_COMPANY_CACHE.get(page_path) if cached is not None: return cached - menu = Menu.objects.filter(MenuLink=page_path).first() + menu = MenuManager.GetByLink(page_path) if menu is None or menu.CompanyUUID is None: _MENU_COMPANY_CACHE[page_path] = (None, False) return None, False diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py index 8395816e..c8df83ac 100644 --- a/gvsds/apps/gerp/app_manager.py +++ b/gvsds/apps/gerp/app_manager.py @@ -17,14 +17,9 @@ import datetime import uuid +from gvsdsdk.auth.managers import AppManager as SdkAppManager, CompanyManager, PositionManager from gvsdsdk.model_utils import UUIDObj -from .models import ( - App, TenantApp, CompanyApp, UserApp, AppPosition, - AppVirtualPosition, AppVirtualBinding, - UserRole, UserRoleDepartment, UserRoleDepartmentPosition, -) - # 投放形态 TYPE_ENTERPRISE = 'enterprise' TYPE_PERSONAL = 'personal' @@ -73,32 +68,13 @@ def _ToStr(value): def _GetUserPositionUUIDs(user_bytes, RoleUUID=None): - """用户职务集合(bytes set):UserRole → UserRoleDepartment → UserRoleDepartmentPosition + """用户职务集合(bytes set):委托 gvsdsdk PositionManager.GetUserPositionUUIDs 用户通过"用户↔角色"绑定部门后,在部门下担任的职务(身兼数职)构成其职务集合。 传 RoleUUID 时仅统计该角色下的 UserRole 对应的职务(严格按当前角色显示菜单: 同一用户兼任多角色时,各角色按自身职务独立判定应用可见性)。 """ - if not user_bytes: - return set() - ur_qs = UserRole.objects.filter(UserUUID=user_bytes) - if RoleUUID: - role_bytes = _ToBytes(RoleUUID) - if role_bytes: - ur_qs = ur_qs.filter(RoleUUID=role_bytes) - ur_uuids = list(ur_qs.values_list('UserRoleUUID', flat=True)) - if not ur_uuids: - return set() - urd_uuids = list( - UserRoleDepartment.objects.filter(UserRoleUUID__in=ur_uuids) - .values_list('UserRoleDepartmentUUID', flat=True) - ) - if not urd_uuids: - return set() - return set( - UserRoleDepartmentPosition.objects.filter(UserRoleDepartmentUUID__in=urd_uuids) - .values_list('PositionUUID', flat=True) - ) + return PositionManager.GetUserPositionUUIDs(user_bytes, RoleUUID=RoleUUID) def _AppVisibleForPosition(app, user_position_uuids): @@ -108,16 +84,8 @@ def _AppVisibleForPosition(app, user_position_uuids): 宽松模式(StrictPositionVisible=0,默认)→ 全员可见(True,兼容存量) 严格模式(StrictPositionVisible=1)→ 全员不可见(False,必须绑定职务才可见) """ - bound = set( - AppPosition.objects.filter(AppUUID=app.AppUUID) - .values_list('PositionUUID', flat=True) - ) - # 虚拟职务绑定同样计入"绑定职务"集合:管理员把实际职务绑定到应用虚拟职务后, - # 拥有这些实际职务的用户视为"已绑定职务" - bound |= set( - AppVirtualBinding.objects.filter(AppUUID=app.AppUUID) - .values_list('PositionUUID', flat=True) - ) + # 绑定职务集合(AppPosition ∪ AppVirtualBinding)由 SDK 提供 + bound = PositionManager.GetAppBoundPositionUUIDs(app.AppUUID) if not bound: # 无绑定记录:管理员开关(StrictPositionVisible)或应用清单声明的强制职务(RequirePositionBinding) # 任一为 1 → 全员不可见(必须绑定职务才可见);均为 0 → 宽松全员可见(兼容存量) @@ -181,16 +149,16 @@ class AppManager: @staticmethod def ListEnabledApps(): """全部启用中的应用(AppStatus=1)""" - return list(App.objects.filter(AppStatus=1).order_by('AppSort', 'CreateTime')) + return list(SdkAppManager.ListEnabledApps()) @staticmethod def GetAppByCode(AppCode): """按应用编码查应用(禁用也返回,供访问校验)""" - return App.objects.filter(AppCode=AppCode).first() + return SdkAppManager.GetAppByCode(AppCode) @staticmethod def GetApp(AppUUID): - return App.objects.filter(AppUUID=_ToBytes(AppUUID)).first() + return SdkAppManager.GetApp(AppUUID) @staticmethod def SetStrictPositionVisible(AppUUID, StrictPositionVisible): @@ -198,8 +166,7 @@ class AppManager: app = AppManager.GetApp(AppUUID) if app is None: return False, '应用不存在' - app.StrictPositionVisible = 1 if StrictPositionVisible else 0 - app.save(update_fields=['StrictPositionVisible']) + SdkAppManager.SetStrictPositionVisible(AppUUID, 1 if StrictPositionVisible else 0) return True, '严格可见模式已' + ('开启' if StrictPositionVisible else '关闭') @staticmethod @@ -218,28 +185,31 @@ class AppManager: None 该应用未声明虚拟职务(不做菜单级过滤,维持现状) """ app_bytes = _ToBytes(AppUUID) - if not AppVirtualPosition.objects.filter(AppUUID=app_bytes).exists(): + vps = PositionManager.ListVirtualPositions(AppUUID=app_bytes) + if not vps: return None # 应用声明了虚拟职务但用户无任何实际职务:不可见任何应用菜单 # (空集合 → menu.py 隐藏整个应用菜单,不做"无职务全放行") if not UserPositionUUIDs: return set() - binding_qs = AppVirtualBinding.objects.filter( - AppUUID=app_bytes, - PositionUUID__in=[_ToBytes(p) for p in UserPositionUUIDs], - ) - if CompanyUUID: - company_bytes = _ToBytes(CompanyUUID) - if company_bytes: - binding_qs = binding_qs.filter(CompanyUUID=company_bytes) - binding_vp = set(binding_qs.values_list('VirtualPositionUUID', flat=True)) + # 用户实际职务集合(统一转规范 UUID 字符串,与绑定记录回显口径一致) + user_pos_strs = set() + for p in UserPositionUUIDs: + pb = _ToBytes(p) + if pb: + user_pos_strs.add(str(UUIDObj(pb))) + binding_vp = set() + for b in PositionManager.GetVirtualBindings(AppUUID, CompanyUUID): + if b['PositionUUID'] in user_pos_strs: + binding_vp.add(b['VirtualPositionUUID']) if not binding_vp: return set() names = set() - for avp in AppVirtualPosition.objects.filter(AppVirtualPositionUUID__in=binding_vp): - for n in (avp.MenuNames or '').split(';'): - if n.strip(): - names.add(n.strip()) + for avp in vps: + if str(UUIDObj(avp.AppVirtualPositionUUID)) in binding_vp: + for n in (avp.MenuNames or '').split(';'): + if n.strip(): + names.add(n.strip()) return names @staticmethod @@ -254,20 +224,18 @@ class AppManager: AllPositions=[{PositionUUID, PositionName}](当前租户全部职务,选择用) """ app_bytes = _ToBytes(AppUUID) - vps = list(AppVirtualPosition.objects.filter(AppUUID=app_bytes).order_by('Code')) + vps = PositionManager.ListVirtualPositions(AppUUID=app_bytes) bind_map = {} pos_name_map = {} if vps: # 绑定查询(含职务名回显)委托 gvsdsdk PositionManager.GetVirtualBindings - from gvsdsdk.auth.managers import PositionManager for b in PositionManager.GetVirtualBindings(AppUUID, CompanyUUID): bind_map.setdefault(b['VirtualPositionUUID'], []).append(b['PositionUUID']) if b['PositionUUID']: pos_name_map[b['PositionUUID']] = b['PositionName'] positions = [] if TenantUUID: - from gvsdsdk.models import Position as SdkPosition - for p in SdkPosition.objects.filter(TenantUUID=_ToBytes(TenantUUID)).order_by('PositionName'): + for p in PositionManager.ListPositions(TenantUUID, OrderByName=True): positions.append({ 'PositionUUID': _ToStr(p.PositionUUID), 'PositionName': p.PositionName, @@ -295,11 +263,10 @@ class AppManager: """重写虚拟职务↔真实职务绑定(配置页保存)。 逐条委托 gvsdsdk PositionManager.SetVirtualBindings(仅重写该公司维度,其它公司保留)。""" - from gvsdsdk.auth.managers import PositionManager app_bytes = _ToBytes(AppUUID) vp_map = { _ToStr(v.AppVirtualPositionUUID): v - for v in AppVirtualPosition.objects.filter(AppUUID=app_bytes) + for v in PositionManager.ListVirtualPositions(AppUUID=app_bytes) } for m in (Mappings or []): vp = vp_map.get(_ToStr(m.get('AppVirtualPositionUUID'))) @@ -321,9 +288,7 @@ class AppManager: Returns: (ok, msg, stats): stats={'Bound': [虚拟职务名], 'Unmatched': [虚拟职务名]} """ - from gvsdsdk.auth.managers import PositionManager - app_bytes = _ToBytes(AppUUID) - app = App.objects.filter(AppUUID=app_bytes).first() + app = AppManager.GetApp(AppUUID) if app is None: return False, '应用不存在', None if not _ToBytes(TenantUUID): @@ -334,8 +299,7 @@ class AppManager: def _FindAppManifest(AppUUID): """按 AppUUID 定位 App 记录及其清单(含 PositionTemplates 声明)""" from .app_manifests import ListAppManifests - app_bytes = _ToBytes(AppUUID) - app = App.objects.filter(AppUUID=app_bytes).first() + app = AppManager.GetApp(AppUUID) if app is None: return None, None for m in ListAppManifests(): @@ -356,8 +320,6 @@ class AppManager: 'NeedSync': bool, # 是否存在待新增项 } """ - from gvsdsdk.models import Position as SdkPosition - from gvsdsdk.models import PositionCategory as SdkPositionCategory app, manifest = AppManager._FindAppManifest(AppUUID) if manifest is None: return {'Categories': [], 'NeedSync': False} @@ -365,10 +327,8 @@ class AppManager: cat_names = set() pos_names = set() if tenant_bytes: - cat_names = set(SdkPositionCategory.objects.filter( - TenantUUID=tenant_bytes).values_list('CategoryName', flat=True)) - pos_names = set(SdkPosition.objects.filter( - TenantUUID=tenant_bytes).values_list('PositionName', flat=True)) + cat_names = {c.CategoryName for c in PositionManager.GetCategoriesForTenant(tenant_bytes)} + pos_names = {p.PositionName for p in PositionManager.ListPositions(tenant_bytes)} categories = [] need_sync = False for spec in manifest.PositionTemplates: @@ -403,8 +363,6 @@ class AppManager: 'ReusedCategories': int, 'ReusedPositions': int, } """ - from gvsdsdk.models import Position as SdkPosition - from gvsdsdk.models import PositionCategory as SdkPositionCategory app, manifest = AppManager._FindAppManifest(AppUUID) if app is None: return False, '应用不存在', None @@ -417,9 +375,9 @@ class AppManager: def _gen_code(prefix): return f'{prefix}-{uuid.uuid4().hex[:6].upper()}' - cats = list(SdkPositionCategory.objects.filter(TenantUUID=tenant_bytes)) + cats = list(PositionManager.GetCategoriesForTenant(tenant_bytes)) cat_by_name = {c.CategoryName: c for c in cats} - pos_by_name = {p.PositionName: p for p in SdkPosition.objects.filter(TenantUUID=tenant_bytes)} + pos_by_name = {p.PositionName: p for p in PositionManager.ListPositions(tenant_bytes)} created_cats = [] reused_cats = 0 created_pos = [] @@ -427,14 +385,8 @@ class AppManager: for spec in manifest.PositionTemplates: cat = cat_by_name.get(spec.CategoryName) if cat is None: - cat = SdkPositionCategory.objects.create( - PositionCategoryUUID=uuid.uuid4().bytes, - TenantUUID=tenant_bytes, - CategoryName=spec.CategoryName, - CategoryCode=_gen_code('CAT'), - CategorySort=0, - CategoryStatus=1, - ) + cat = PositionManager.CreateCategory( + spec.CategoryName, tenant_bytes, _gen_code('CAT')) cat_by_name[spec.CategoryName] = cat created_cats.append(spec.CategoryName) else: @@ -442,14 +394,8 @@ class AppManager: for pn in spec.Positions: pos = pos_by_name.get(pn) if pos is None: - SdkPosition.objects.create( - PositionUUID=uuid.uuid4().bytes, - TenantUUID=tenant_bytes, - PositionName=pn, - PositionCode=_gen_code('POS'), - PositionLevel=1, - PositionStatus=1, - PositionDesc='', + PositionManager.CreatePosition( + pn, tenant_bytes, Code=_gen_code('POS'), Level=1, CategoryUUID=cat.PositionCategoryUUID, ) pos_by_name[pn] = True @@ -457,8 +403,8 @@ class AppManager: else: # 已有职务复用:未归属通用部门则挂靠到该部门 if not pos.CategoryUUID: - pos.CategoryUUID = cat.PositionCategoryUUID - pos.save(update_fields=['CategoryUUID']) + PositionManager.UpdatePosition( + pos.PositionUUID, CategoryUUID=cat.PositionCategoryUUID) reused_pos += 1 stats = { 'CreatedCategories': created_cats, @@ -471,9 +417,8 @@ class AppManager: @staticmethod def _HeldPositionUUIDs(): - """所有员工当前持有的职务 UUID bytes 集合(UserRoleDepartmentPosition 引用)""" - from .models import UserRoleDepartmentPosition - return {_ToBytes(r.PositionUUID) for r in UserRoleDepartmentPosition.objects.all()} + """所有员工当前持有的职务 UUID bytes 集合(委托 SDK PositionManager)""" + return PositionManager.GetHeldPositionUUIDs() @staticmethod def GetPositionTemplateRemovePreview(AppUUID, TenantUUID=None): @@ -491,8 +436,6 @@ class AppManager: 'AnyRemovable': bool, } """ - from gvsdsdk.models import Position as SdkPosition - from gvsdsdk.models import PositionCategory as SdkPositionCategory app, manifest = AppManager._FindAppManifest(AppUUID) if manifest is None: return {'Categories': [], 'AnyRemovable': False} @@ -500,8 +443,8 @@ class AppManager: cats = {} positions = {} if tenant_bytes: - cats = {c.CategoryName: c for c in SdkPositionCategory.objects.filter(TenantUUID=tenant_bytes)} - positions = {p.PositionName: p for p in SdkPosition.objects.filter(TenantUUID=tenant_bytes)} + cats = {c.CategoryName: c for c in PositionManager.GetCategoriesForTenant(tenant_bytes)} + positions = {p.PositionName: p for p in PositionManager.ListPositions(tenant_bytes)} held_uuids = AppManager._HeldPositionUUIDs() categories = [] any_removable = False @@ -533,8 +476,8 @@ class AppManager: cat_removable = False if cat is not None and not cat_blocked: declared = set(spec.Positions) - for cp in SdkPosition.objects.filter( - TenantUUID=tenant_bytes, CategoryUUID=cat.PositionCategoryUUID): + for cp in PositionManager.ListPositions( + tenant_bytes, CategoryUUID=cat.PositionCategoryUUID): if cp.PositionName not in declared: dept_has_other = True break @@ -570,8 +513,6 @@ class AppManager: 'BlockedPositions': [名称](员工仍持有,保留), } """ - from gvsdsdk.models import Position as SdkPosition - from gvsdsdk.models import PositionCategory as SdkPositionCategory app, manifest = AppManager._FindAppManifest(AppUUID) if app is None: return False, '应用不存在', None @@ -581,30 +522,27 @@ class AppManager: if not tenant_bytes: return False, '无法确定当前租户', None - from .models import AppVirtualBinding declared_cats = {spec.CategoryName for spec in manifest.PositionTemplates} declared_pos = {pn for spec in manifest.PositionTemplates for pn in spec.Positions} held_uuids = AppManager._HeldPositionUUIDs() removed_pos = [] blocked_pos = [] - for p in SdkPosition.objects.filter( - TenantUUID=tenant_bytes, PositionName__in=declared_pos): + for p in PositionManager.ListPositions(tenant_bytes, Names=declared_pos): if _ToBytes(p.PositionUUID) in held_uuids: blocked_pos.append(p.PositionName) continue # 移除职务时同步清理虚拟职务绑定,避免悬空引用 - AppVirtualBinding.objects.filter(PositionUUID=p.PositionUUID).delete() - p.delete() + PositionManager.RemoveVirtualBindings(PositionUUID=p.PositionUUID) + PositionManager.PurgePosition(p.PositionUUID) removed_pos.append(p.PositionName) removed_cats = [] - for c in SdkPositionCategory.objects.filter( - TenantUUID=tenant_bytes, CategoryName__in=declared_cats): - remaining = SdkPosition.objects.filter( - TenantUUID=tenant_bytes, CategoryUUID=c.PositionCategoryUUID).count() + for c in PositionManager.GetCategoriesForTenant(tenant_bytes, Names=declared_cats): + remaining = PositionManager.ListPositions( + tenant_bytes, CategoryUUID=c.PositionCategoryUUID).count() if remaining == 0: - c.delete() + PositionManager.PurgeCategory(c.PositionCategoryUUID) removed_cats.append(c.CategoryName) stats = { @@ -639,30 +577,25 @@ class AppManager: company_bytes = _ToBytes(CompanyUUID) tenant_map = {} if tenant_bytes: - for ta in TenantApp.objects.filter(TenantUUID=tenant_bytes): + for ta in SdkAppManager.ListTenantApps(tenant_bytes): tenant_map[UUIDObj(ta.AppUUID).str] = ta company_map = {} if company_bytes: - for ca in CompanyApp.objects.filter(CompanyUUID=company_bytes): + for ca in SdkAppManager.ListCompanyApps(company_bytes): company_map[UUIDObj(ca.AppUUID).str] = ca user_map = {} if user_bytes: - for ua in UserApp.objects.filter(UserUUID=user_bytes): + for ua in SdkAppManager.ListUserApps(user_bytes): user_map[UUIDObj(ua.AppUUID).str] = ua # 管理员视角:可管理范围内已装各应用的公司名(app str -> [公司名]) installed_companies_map = {} manageable_bytes = [_ToBytes(c) for c in (ManageableCompanies or []) if _ToBytes(c)] if manageable_bytes: - from gvsdsdk.models import Company as SdkCompany - company_name_map = { - _ToBytes(c.CompanyUUID): c.CompanyName - for c in SdkCompany.objects.filter(CompanyUUID__in=manageable_bytes) - } + from gvsdsdk.auth.managers import CompanyManager + company_name_map = CompanyManager.GetCompanyNameMap(manageable_bytes) app_bytes_list = [_ToBytes(a.AppUUID) for a in apps] - for ca in CompanyApp.objects.filter( - AppUUID__in=app_bytes_list, - CompanyUUID__in=manageable_bytes, - InstallStatus=1): + for ca in SdkAppManager.ListCompanyAppInstalls( + app_bytes_list, manageable_bytes, InstallStatus=1): name = company_name_map.get(_ToBytes(ca.CompanyUUID)) if name: installed_companies_map.setdefault(UUIDObj(ca.AppUUID).str, []).append(name) @@ -699,15 +632,15 @@ class AppManager: company_bytes = _ToBytes(CompanyUUID) tenant_map = {} if tenant_bytes: - for ta in TenantApp.objects.filter(TenantUUID=tenant_bytes, InstallStatus=1): + for ta in SdkAppManager.ListTenantApps(tenant_bytes, InstallStatus=1): tenant_map[UUIDObj(ta.AppUUID).str] = ta company_map = {} if company_bytes: - for ca in CompanyApp.objects.filter(CompanyUUID=company_bytes, InstallStatus=1): + for ca in SdkAppManager.ListCompanyApps(company_bytes, InstallStatus=1): company_map[UUIDObj(ca.AppUUID).str] = ca user_map = {} if user_bytes: - for ua in UserApp.objects.filter(UserUUID=user_bytes, UseStatus=1): + for ua in SdkAppManager.ListUserApps(user_bytes, UseStatus=1): user_map[UUIDObj(ua.AppUUID).str] = ua result = [] # 职务绑定可见性:应用绑定职务时,用户当前角色职务集合须命中(无绑定=全员可见) @@ -755,21 +688,8 @@ class AppManager: return False, '应用不存在或已禁用', None if app.AppType == TYPE_PERSONAL: return False, '该应用为个人应用,不支持企业安装', None - ta, created = TenantApp.objects.get_or_create( - TenantUUID=tenant_bytes, - AppUUID=app.AppUUID, - defaults={ - 'TenantAppUUID': uuid.uuid4().bytes, - 'InstallStatus': 1, - 'AuthStatus': AUTH_OK if app.AuthRequired == 1 else AUTH_NONE, - 'InstallTime': datetime.datetime.utcnow(), - }, - ) - if not created: - ta.InstallStatus = 1 - if app.AuthRequired == 1 and ta.AuthStatus != AUTH_OK: - ta.AuthStatus = AUTH_OK - ta.save(update_fields=['InstallStatus', 'AuthStatus']) + ta, created = SdkAppManager.InstallTenantApp( + tenant_bytes, app.AppUUID, AuthRequired=app.AuthRequired) # 真应用协议:安装后注入菜单(MenuStatus=1) try: from .app_manifests import InjectAppMenus @@ -781,14 +701,7 @@ class AppManager: @staticmethod def _AnyEnabledInstall(AppUUID): """应用是否仍存在任意启用中的安装/启用记录(跨租户/公司/个人)""" - app_bytes = _ToBytes(AppUUID) - if TenantApp.objects.filter(AppUUID=app_bytes, InstallStatus=1).exists(): - return True - if CompanyApp.objects.filter(AppUUID=app_bytes, InstallStatus=1).exists(): - return True - if UserApp.objects.filter(AppUUID=app_bytes, UseStatus=1).exists(): - return True - return False + return SdkAppManager.HasEnabledInstall(AppUUID) @staticmethod def _MaybeRemoveAppMenus(AppUUID): @@ -807,11 +720,8 @@ class AppManager: def UninstallTenantApp(TenantUUID, AppUUID): """租户停用企业功能""" tenant_bytes = _ToBytes(TenantUUID) - ta = TenantApp.objects.filter(TenantUUID=tenant_bytes, AppUUID=_ToBytes(AppUUID)).first() - if ta is None: + if not SdkAppManager.UninstallTenantApp(tenant_bytes, AppUUID): return False, '该功能未安装' - ta.InstallStatus = 0 - ta.save(update_fields=['InstallStatus']) AppManager._MaybeRemoveAppMenus(AppUUID) return True, '' @@ -839,21 +749,7 @@ class AppManager: return False, '未指定要安装的公司', 0 install_by = _ToBytes(InstallBy) for company_bytes in company_list: - ca, created = CompanyApp.objects.get_or_create( - CompanyUUID=company_bytes, - AppUUID=app.AppUUID, - defaults={ - 'CompanyAppUUID': uuid.uuid4().bytes, - 'InstallStatus': 1, - 'InstallTime': datetime.datetime.utcnow(), - 'InstallBy': install_by, - }, - ) - if not created: - ca.InstallStatus = 1 - if install_by: - ca.InstallBy = install_by - ca.save(update_fields=['InstallStatus', 'InstallBy']) + SdkAppManager.InstallCompanyApp(company_bytes, app.AppUUID, InstallBy=install_by) # 真应用协议:安装后注入菜单(MenuStatus=1) try: from .app_manifests import InjectAppMenus @@ -871,14 +767,8 @@ class AppManager: app_bytes = _ToBytes(AppUUID) updated = 0 for company_bytes in company_list: - ca = CompanyApp.objects.filter( - CompanyUUID=company_bytes, AppUUID=app_bytes, - ).first() - if ca is None: - continue - ca.InstallStatus = 0 - ca.save(update_fields=['InstallStatus']) - updated += 1 + if SdkAppManager.UninstallCompanyApp(company_bytes, app_bytes): + updated += 1 if updated == 0: return False, '所选公司均未安装该功能' AppManager._MaybeRemoveAppMenus(AppUUID) @@ -891,7 +781,7 @@ class AppManager: if not company_bytes: return {} result = {} - for ca in CompanyApp.objects.filter(CompanyUUID=company_bytes): + for ca in SdkAppManager.ListCompanyApps(company_bytes): app = AppManager.GetApp(ca.AppUUID) if app is None: continue @@ -921,7 +811,7 @@ class AppManager: if not company_list: return {} result = {c: 0 for c in company_list} - for ca in CompanyApp.objects.filter(AppUUID=app_bytes, CompanyUUID__in=company_list): + for ca in SdkAppManager.ListCompanyAppInstalls([app_bytes], company_list): result[_ToBytes(ca.CompanyUUID)] = ca.InstallStatus return result @@ -963,7 +853,6 @@ class AppManager: Returns: list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated} """ - from .models import AppConfig app = AppManager.GetApp(AppUUID) if app is None: return [] @@ -975,13 +864,11 @@ class AppManager: company_bytes = _ToBytes(CompanyUUID) saved_company = {} if company_bytes: - for cfg in AppConfig.objects.filter( - TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=company_bytes): + for cfg in SdkAppManager.ListAppConfigs(tenant_bytes, app_bytes, company_bytes): saved_company[cfg.ConfigKey] = cfg saved_tenant = {} if tenant_bytes: - for cfg in AppConfig.objects.filter( - TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True): + for cfg in SdkAppManager.ListAppConfigs(tenant_bytes, app_bytes, None): saved_tenant[cfg.ConfigKey] = cfg result = [] for t in template: @@ -1012,8 +899,6 @@ class AppManager: Returns: (ok, err, updated_keys) """ - import datetime as _dt - from .models import AppConfig app = AppManager.GetApp(AppUUID) if app is None: return False, '应用不存在', [] @@ -1031,23 +916,10 @@ class AppManager: continue if not isinstance(value, str): value = str(value) - cfg, created = AppConfig.objects.get_or_create( - TenantUUID=tenant_bytes, - CompanyUUID=company_bytes, - AppUUID=app_bytes, - ConfigKey=key, - defaults={ - 'AppConfigUUID': uuid.uuid4().bytes, - 'ConfigValue': value, - 'UpdateBy': operator_bytes, - 'UpdateTime': _dt.datetime.utcnow(), - }, + SdkAppManager.UpsertAppConfig( + tenant_bytes, app_bytes, key, value, + CompanyUUID=company_bytes, UpdateBy=operator_bytes, ) - if not created: - cfg.ConfigValue = value - cfg.UpdateBy = operator_bytes - cfg.UpdateTime = _dt.datetime.utcnow() - cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime']) updated.append(key) return True, '', updated @@ -1065,44 +937,27 @@ class AppManager: Returns: (ok, err, copied_count) """ - import datetime as _dt - from .models import AppConfig tenant_bytes = _ToBytes(TenantUUID) app_bytes = _ToBytes(AppUUID) source_bytes = _ToBytes(SourceCompanyUUID) if source_bytes: - source_rows = list(AppConfig.objects.filter( - TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=source_bytes)) + source_rows = SdkAppManager.ListAppConfigs(tenant_bytes, app_bytes, source_bytes) else: - source_rows = list(AppConfig.objects.filter( - TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True)) + source_rows = SdkAppManager.ListAppConfigs(tenant_bytes, app_bytes, None) if not source_rows: return False, '源配置不存在(无已保存的配置项)', 0 targets = [_ToBytes(c) for c in (TargetCompanyUUIDs or []) if _ToBytes(c)] if not targets: return False, '未指定目标公司', 0 operator_bytes = _ToBytes(OperatorUUID) - now = _dt.datetime.utcnow() + now = datetime.datetime.utcnow() copied = 0 for company_bytes in targets: for row in source_rows: - cfg, created = AppConfig.objects.get_or_create( - TenantUUID=tenant_bytes, - CompanyUUID=company_bytes, - AppUUID=app_bytes, - ConfigKey=row.ConfigKey, - defaults={ - 'AppConfigUUID': uuid.uuid4().bytes, - 'ConfigValue': row.ConfigValue, - 'UpdateBy': operator_bytes, - 'UpdateTime': now, - }, + SdkAppManager.UpsertAppConfig( + tenant_bytes, app_bytes, row.ConfigKey, row.ConfigValue, + CompanyUUID=company_bytes, UpdateBy=operator_bytes, UpdateTime=now, ) - if not created: - cfg.ConfigValue = row.ConfigValue - cfg.UpdateBy = operator_bytes - cfg.UpdateTime = now - cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime']) copied += 1 return True, '', copied @@ -1124,20 +979,16 @@ class AppManager: return False, '应用不存在或已禁用', None if app.AppType == TYPE_ENTERPRISE: return False, '该应用为企业功能,由租户管理员统一开通', None - ua, created = UserApp.objects.get_or_create( - UserUUID=user_bytes, - AppUUID=app.AppUUID, - defaults={'UserAppUUID': uuid.uuid4().bytes}, - ) + ua, created = SdkAppManager.GetOrCreateUserApp(user_bytes, app.AppUUID) if app.AuthRequired == 1: if ua.ApplyStatus != APPLY_APPROVED: return False, '该应用需企业授权,请先申请(待租户管理员审批)', None + SdkAppManager.SetUserAppUseStatus(user_bytes, app.AppUUID, 1) ua.UseStatus = 1 - ua.save(update_fields=['UseStatus']) AppManager._InjectMenusSafe(app.AppCode) return True, '', _AppFlat(app, user_record=ua) + SdkAppManager.SetUserAppUseStatus(user_bytes, app.AppUUID, 1) ua.UseStatus = 1 - ua.save(update_fields=['UseStatus']) AppManager._InjectMenusSafe(app.AppCode) return True, '', _AppFlat(app, user_record=ua) @@ -1154,11 +1005,8 @@ class AppManager: def DisableUserApp(UserUUID, AppUUID): """个人停用应用""" user_bytes = _ToBytes(UserUUID) - ua = UserApp.objects.filter(UserUUID=user_bytes, AppUUID=_ToBytes(AppUUID)).first() - if ua is None: + if not SdkAppManager.SetUserAppUseStatus(user_bytes, AppUUID, 0): return False, '未启用该应用' - ua.UseStatus = 0 - ua.save(update_fields=['UseStatus']) AppManager._MaybeRemoveAppMenus(AppUUID) return True, '' @@ -1171,16 +1019,11 @@ class AppManager: return False, '应用不存在或已禁用' if app.AuthRequired != 1: return False, '该应用无需企业授权,可直接启用' - ua, created = UserApp.objects.get_or_create( - UserUUID=user_bytes, - AppUUID=app.AppUUID, - defaults={'UserAppUUID': uuid.uuid4().bytes}, - ) + ua, created = SdkAppManager.GetOrCreateUserApp(user_bytes, app.AppUUID) if ua.ApplyStatus == APPLY_APPROVED: return False, '该应用已授权,可直接启用' - ua.ApplyStatus = APPLY_PENDING - ua.ApplyTime = datetime.datetime.utcnow() - ua.save(update_fields=['ApplyStatus', 'ApplyTime']) + SdkAppManager.SetUserAppApplyStatus( + ua.UserAppUUID, APPLY_PENDING, ApplyTime=datetime.datetime.utcnow()) return True, '' @staticmethod @@ -1190,25 +1033,31 @@ class AppManager: Returns: (ok, err, flat) """ - ua = UserApp.objects.filter(UserAppUUID=_ToBytes(UserAppUUID)).first() + ua = SdkAppManager.GetUserAppByUUID(UserAppUUID) if ua is None: return False, '申请记录不存在' if ua.ApplyStatus != APPLY_PENDING: return False, '该申请已处理(非待审批状态)' - ua.ApplyStatus = APPLY_APPROVED if Approved else APPLY_DENIED - ua.UseStatus = 1 if Approved else 0 - ua.ApproveBy = _ToBytes(OperatorUUID) - ua.ApproveTime = datetime.datetime.utcnow() - ua.save(update_fields=['ApplyStatus', 'UseStatus', 'ApproveBy', 'ApproveTime']) + apply_status = APPLY_APPROVED if Approved else APPLY_DENIED + use_status = 1 if Approved else 0 + approve_by = _ToBytes(OperatorUUID) + approve_time = datetime.datetime.utcnow() + SdkAppManager.SetUserAppApprove( + ua.UserAppUUID, apply_status, use_status, + ApproveBy=approve_by, ApproveTime=approve_time, + ) + ua.ApplyStatus = apply_status + ua.UseStatus = use_status + ua.ApproveBy = approve_by + ua.ApproveTime = approve_time app = AppManager.GetApp(ua.AppUUID) return True, '', _AppFlat(app, user_record=ua) @staticmethod def ListPendingApplications(TenantUUID): """租户内待审批的个人授权申请(供租户管理员审批台)""" - tenant_bytes = _ToBytes(TenantUUID) rows = [] - for ua in UserApp.objects.filter(ApplyStatus=APPLY_PENDING).order_by('-ApplyTime'): + for ua in SdkAppManager.ListPendingApplications(TenantUUID): app = AppManager.GetApp(ua.AppUUID) if app is None: continue @@ -1256,35 +1105,27 @@ class AppManager: if not _AppVisibleForPosition(app, _GetUserPositionUUIDs(user_bytes, RoleUUID=RoleUUID)): return None if app.AppType == TYPE_ENTERPRISE: - ta = TenantApp.objects.filter( - TenantUUID=tenant_bytes, AppUUID=app.AppUUID, InstallStatus=1, - ).first() - if ta: + ta = SdkAppManager.GetTenantApp(tenant_bytes, app.AppUUID) + if ta and ta.InstallStatus == 1: return app # 公司维度安装:仅当传了当前公司且该公司已安装 if company_bytes: - ca = CompanyApp.objects.filter( - CompanyUUID=company_bytes, AppUUID=app.AppUUID, InstallStatus=1, - ).first() - return app if ca else None + ca = SdkAppManager.GetCompanyApp(company_bytes, app.AppUUID) + return app if (ca and ca.InstallStatus == 1) else None return None if app.AppType == TYPE_PERSONAL: - ua = UserApp.objects.filter( - UserUUID=user_bytes, AppUUID=app.AppUUID, UseStatus=1, - ).first() - return app if ua else None + ua = SdkAppManager.GetUserApp(user_bytes, app.AppUUID) + return app if (ua and ua.UseStatus == 1) else None # both - ta = TenantApp.objects.filter(TenantUUID=tenant_bytes, AppUUID=app.AppUUID, InstallStatus=1).first() - if ta: + ta = SdkAppManager.GetTenantApp(tenant_bytes, app.AppUUID) + if ta and ta.InstallStatus == 1: return app if company_bytes: - ca = CompanyApp.objects.filter( - CompanyUUID=company_bytes, AppUUID=app.AppUUID, InstallStatus=1, - ).first() - if ca: + ca = SdkAppManager.GetCompanyApp(company_bytes, app.AppUUID) + if ca and ca.InstallStatus == 1: return app - ua = UserApp.objects.filter(UserUUID=user_bytes, AppUUID=app.AppUUID, UseStatus=1).first() - return app if ua else None + ua = SdkAppManager.GetUserApp(user_bytes, app.AppUUID) + return app if (ua and ua.UseStatus == 1) else None @staticmethod def GetManageableCompanies(request): @@ -1301,26 +1142,19 @@ class AppManager: """ from gvsdsdk.auth.admin_service import IsSuperAdmin, IsTenantAdmin, IsCompanyAdmin if IsSuperAdmin(request=request): - from gvsdsdk.models import Company as SdkCompany - return [_ToBytes(c.CompanyUUID) for c in SdkCompany.objects.filter(CompanyStatus=1)], 'all' + return CompanyManager.ListEnabledCompanyUUIDs(), 'all' tenant_uuid = getattr(request, 'current_tenant_uuid', None) tenant_bytes = _ToBytes(tenant_uuid) if IsTenantAdmin(request=request): - from gvsdsdk.models import Company as SdkCompany - qs = SdkCompany.objects.filter(TenantUUID=tenant_bytes, CompanyStatus=1) - return [_ToBytes(c.CompanyUUID) for c in qs], 'tenant' + if not tenant_bytes: + return [], 'tenant' + return CompanyManager.ListEnabledCompanyUUIDs(tenant_uuid), 'tenant' if IsCompanyAdmin(request=request): # admin:company:取当前角色被授权公司(RolePermission.CompanyUUID 维度) role = getattr(request, 'current_role', None) companies = [] if role is not None: - from gvsdsdk.models import RolePermission, Permission - perm_uuids = list(Permission.objects.filter( - PermCode='admin:company', PermStatus=1, - ).values_list('PermUUID', flat=True)) - for rp in RolePermission.objects.filter( - RoleUUID=role.RoleUUID, PermUUID__in=perm_uuids): - if rp.CompanyUUID and rp.CompanyUUID != b'\x00' * 16: - companies.append(_ToBytes(rp.CompanyUUID)) + from gvsdsdk.auth.managers import RoleManager + companies = RoleManager.GetRolePermissionCompanies(role.RoleUUID, 'admin:company') return companies, 'company' return [], '' diff --git a/gvsds/apps/gerp/app_manifests.py b/gvsds/apps/gerp/app_manifests.py index eb7dab8d..1184e50f 100644 --- a/gvsds/apps/gerp/app_manifests.py +++ b/gvsds/apps/gerp/app_manifests.py @@ -23,6 +23,7 @@ import uuid as _uuid from dataclasses import dataclass, field from typing import List, Optional +from gvsdsdk.auth.managers import AppManager as SdkAppManager, MenuManager, PositionManager from gvsdsdk.model_utils import UUIDObj from gvsdsdk.models import Menu as SdkMenu @@ -569,11 +570,11 @@ def _UpsertMenu(spec: AppMenuSpec) -> SdkMenu: 父菜单(spec.ParentMenuName)不存在时自动创建空壳父菜单(MenuStatus=1), 作为分组挂载点;卸载应用时不删除,避免其他应用/系统依赖丢失。 """ - menu = SdkMenu.objects.filter(MenuName=spec.MenuName).first() + menu = MenuManager.GetByMenuName(spec.MenuName) # 解析挂载父(按父 MenuName 查;不存在则自动创建空壳父菜单) parent_bytes = None if spec.ParentMenuName: - parent = SdkMenu.objects.filter(MenuName=spec.ParentMenuName).first() + parent = MenuManager.GetByMenuName(spec.ParentMenuName) if parent is None: parent_title, parent_icon, parent_sort = PARENT_MENU_TITLES.get( spec.ParentMenuName, (spec.ParentMenuName, '', 0)) @@ -606,22 +607,19 @@ def _UpsertMenu(spec: AppMenuSpec) -> SdkMenu: def _SyncAppRecord(manifest: AppManifest) -> App: """按 AppCode upsert App 记录,回填前端入口字段""" - app = App.objects.filter(AppCode=manifest.AppCode).first() - if app is None: - app = App(AppUUID=_uuid.uuid4().bytes, AppCode=manifest.AppCode) - app.AppName = manifest.AppName - app.AppIcon = manifest.AppIcon - app.AppDesc = manifest.AppDesc - app.AppType = manifest.AppType - app.AuthRequired = manifest.AuthRequired - app.RequirePositionBinding = manifest.RequirePositionBinding - app.AppSort = manifest.AppSort - app.DefaultPerms = manifest.DefaultPerms - app.FrontendRoute = manifest.FrontendRoute - app.FrontendType = manifest.FrontendType - app.LegacySrc = manifest.LegacySrc - app.save() - return app + return SdkAppManager.SaveAppByCode(manifest.AppCode, { + 'AppName': manifest.AppName, + 'AppIcon': manifest.AppIcon, + 'AppDesc': manifest.AppDesc, + 'AppType': manifest.AppType, + 'AuthRequired': manifest.AuthRequired, + 'RequirePositionBinding': manifest.RequirePositionBinding, + 'AppSort': manifest.AppSort, + 'DefaultPerms': manifest.DefaultPerms, + 'FrontendRoute': manifest.FrontendRoute, + 'FrontendType': manifest.FrontendType, + 'LegacySrc': manifest.LegacySrc, + }) def _SyncAppVirtualPositions(manifest: AppManifest, app: App): @@ -631,29 +629,16 @@ def _SyncAppVirtualPositions(manifest: AppManifest, app: App): - 新增/变更:按 Code upsert(Name/MenuNames 随清单刷新) - 清单移除:删除该虚拟职务并连带清理其实际职务绑定(AppVirtualBinding) """ - from .models import AppVirtualBinding, AppVirtualPosition - declared = {spec.Code: spec for spec in manifest.VirtualPositions} - existing = {p.Code: p for p in AppVirtualPosition.objects.filter(AppUUID=app.AppUUID)} + existing = {p.Code: p for p in PositionManager.ListVirtualPositions(AppUUID=app.AppUUID)} for code, avp in existing.items(): if code not in declared: - AppVirtualBinding.objects.filter(VirtualPositionUUID=avp.AppVirtualPositionUUID).delete() - avp.delete() + PositionManager.RemoveVirtualBindings(VirtualPositionUUID=avp.AppVirtualPositionUUID) + PositionManager.DeleteVirtualPositions(AppUUID=app.AppUUID, Codes=[code]) for code, spec in declared.items(): - menu_names = ';'.join(spec.MenuNames) - avp = existing.get(code) - if avp is None: - AppVirtualPosition.objects.create( - AppVirtualPositionUUID=_uuid.uuid4().bytes, - AppUUID=app.AppUUID, - Code=code, - Name=spec.Name or code, - MenuNames=menu_names, - ) - elif avp.Name != (spec.Name or code) or avp.MenuNames != menu_names: - avp.Name = spec.Name or code - avp.MenuNames = menu_names - avp.save(update_fields=['Name', 'MenuNames']) + PositionManager.UpsertVirtualPosition( + app.AppUUID, code, spec.Name or code, ';'.join(spec.MenuNames), + ) def SyncAppManifests() -> List[str]: @@ -699,7 +684,7 @@ def InjectAppMenus(AppCode: str) -> bool: if root_menu is None: root_menu = menu # 回填 MenuRootUUID:指向第一个菜单节点(主入口,供前端打开应用/菜单过滤使用) - app = App.objects.filter(AppCode=AppCode).first() + app = SdkAppManager.GetAppByCode(AppCode) if root_menu is not None and app is not None: if app.MenuRootUUID != root_menu.MenuUUID: app.MenuRootUUID = root_menu.MenuUUID @@ -718,7 +703,7 @@ def RemoveAppMenus(AppCode: str) -> bool: return False names = _ManifestMenuNames(manifest) if names: - SdkMenu.objects.filter(MenuName__in=names).delete() + MenuManager.DeleteByNames(names) return True @@ -773,26 +758,23 @@ def _ParseRemoteManifest(raw: dict) -> Optional[AppManifest]: def _SyncRemoteAppRecord(manifest: AppManifest, service_name: str, service_domain: str) -> App: """远端清单 → App 表 upsert(Source=msync + 提供方服务标记)""" - app = App.objects.filter(AppCode=manifest.AppCode).first() - if app is None: - app = App(AppUUID=_uuid.uuid4().bytes, AppCode=manifest.AppCode) - app.AppName = manifest.AppName - app.AppIcon = manifest.AppIcon - app.AppDesc = manifest.AppDesc - app.AppType = manifest.AppType - app.AuthRequired = manifest.AuthRequired - app.RequirePositionBinding = manifest.RequirePositionBinding - app.AppSort = manifest.AppSort - app.DefaultPerms = manifest.DefaultPerms - app.FrontendRoute = manifest.FrontendRoute - app.FrontendType = manifest.FrontendType - app.LegacySrc = manifest.LegacySrc - app.Source = 'msync' - app.ServiceName = service_name or '' - app.ServiceDomain = service_domain or '' - app.ServiceOnline = 1 # 上报即在线 - app.save() - return app + return SdkAppManager.SaveAppByCode(manifest.AppCode, { + 'AppName': manifest.AppName, + 'AppIcon': manifest.AppIcon, + 'AppDesc': manifest.AppDesc, + 'AppType': manifest.AppType, + 'AuthRequired': manifest.AuthRequired, + 'RequirePositionBinding': manifest.RequirePositionBinding, + 'AppSort': manifest.AppSort, + 'DefaultPerms': manifest.DefaultPerms, + 'FrontendRoute': manifest.FrontendRoute, + 'FrontendType': manifest.FrontendType, + 'LegacySrc': manifest.LegacySrc, + 'Source': 'msync', + 'ServiceName': service_name or '', + 'ServiceDomain': service_domain or '', + 'ServiceOnline': 1, # 上报即在线 + }) def MarkServiceAppsOffline(ServiceName: str) -> int: @@ -806,9 +788,7 @@ def MarkServiceAppsOffline(ServiceName: str) -> int: """ if not ServiceName: return 0 - return App.objects.filter(Source='msync', ServiceName=ServiceName, ServiceOnline=1).update( - ServiceOnline=0, - ) + return SdkAppManager.SetServiceOnline(ServiceName, False) def MarkServiceAppsOnline(ServiceName: str) -> int: @@ -822,9 +802,7 @@ def MarkServiceAppsOnline(ServiceName: str) -> int: """ if not ServiceName: return 0 - return App.objects.filter(Source='msync', ServiceName=ServiceName, ServiceOnline=0).update( - ServiceOnline=1, - ) + return SdkAppManager.SetServiceOnline(ServiceName, True) def SyncRemoteAppManifests(Manifests: List[dict], ServiceName: str, ServiceDomain: str) -> dict: diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index 65d22250..b4464c84 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -888,9 +888,10 @@ class FormService(FormServiceBase): from gvsdsdk.model_utils import UUIDObj as _UO9 col['title'] = '应用' col['type'] = 'select' + from gvsdsdk.auth.managers import AppManager as _SdkAppManager col['options'] = [ {'value': _UO9(a.AppUUID).str, 'label': a.AppName} - for a in App.objects.filter(AppStatus=1).order_by('AppSort', 'CreateTime') + for a in _SdkAppManager.ListEnabledApps() ] col['editable'] = True if table_name == 'AppPosition' and pascal_name == 'PositionUUID': @@ -908,9 +909,10 @@ class FormService(FormServiceBase): from gvsdsdk.model_utils import UUIDObj as _UO9 col['title'] = '应用' col['type'] = 'select' + from gvsdsdk.auth.managers import AppManager as _SdkAppManager col['options'] = [ {'value': _UO9(a.AppUUID).str, 'label': a.AppName} - for a in App.objects.filter(AppStatus=1).order_by('AppSort', 'CreateTime') + for a in _SdkAppManager.ListEnabledApps() ] col['editable'] = True if table_name == 'AppVirtualPosition' and pascal_name == 'Code': @@ -930,21 +932,23 @@ class FormService(FormServiceBase): from gvsdsdk.model_utils import UUIDObj as _UO9 col['title'] = '应用' col['type'] = 'select' + from gvsdsdk.auth.managers import AppManager as _SdkAppManager col['options'] = [ {'value': _UO9(a.AppUUID).str, 'label': a.AppName} - for a in App.objects.filter(AppStatus=1).order_by('AppSort', 'CreateTime') + for a in _SdkAppManager.ListEnabledApps() ] col['editable'] = True if table_name == 'AppVirtualBinding' and pascal_name == 'VirtualPositionUUID': from gvsdsdk.model_utils import UUIDObj as _UO9 col['title'] = '虚拟职务' col['type'] = 'select' + from gvsdsdk.auth.managers import AppManager as _SdkAppManager, PositionManager as _SdkPositionManager _app_name_map = { _UO9(_a.AppUUID).str: _a.AppName - for _a in App.objects.all() + for _a in _SdkAppManager.ListApps() } _vp_options = [] - for _vp in AppVirtualPosition.objects.all().order_by('Code'): + for _vp in _SdkPositionManager.ListVirtualPositions(): _vp_options.append({ 'value': _UO9(_vp.AppVirtualPositionUUID).str, 'label': f'{_app_name_map.get(_UO9(_vp.AppUUID).str, "")} - {_vp.Name or _vp.Code}', @@ -1758,14 +1762,13 @@ class FormService(FormServiceBase): tenant_bytes = tenant_uuid.bytes if hasattr(tenant_uuid, 'bytes') else _UO6(str(tenant_uuid)).bytes except Exception: tenant_bytes = tenant_uuid - cat = PositionCategory.objects.filter(PositionCategoryUUID=cat_bytes).first() + from gvsdsdk.auth.managers import PositionManager + cat = PositionManager.GetCategoryByUUID(cat_bytes) prefix = (cat.CategoryCode or '').strip().upper() if cat else '' if prefix: - existing = Position.objects.filter( - TenantUUID=tenant_bytes, - CategoryUUID=cat_bytes, - PositionCode__startswith=prefix, - ).values_list('PositionCode', flat=True) + existing = PositionManager.ListPositionCodes( + tenant_bytes, CategoryUUID=cat_bytes, Prefix=prefix, + ) max_no = 0 for code in existing: tail = str(code or '')[len(prefix):] @@ -1892,7 +1895,8 @@ class FormService(FormServiceBase): # CompanyInfo 唯一性:同一租户仅一条企业信息(epcreate 是"完善本企业"向导, # 防重复提交产生多条;修改走租户详情编辑) if table_name == 'CompanyInfo' and self.current_tenant_uuid: - if CompanyInfo.objects.filter(TenantUUID=self.current_tenant_uuid).exists(): + from gvsdsdk.auth.managers import CompanyManager + if CompanyManager.HasCompanyInfo(self.current_tenant_uuid): return {'error': '该租户已存在企业信息,如需修改请在租户详情中编辑', 'field': 'Table'} pk_field_name = model._meta.pk.name if model._meta.pk else None # 关联表主键由后端强制生成(UserRole/UserRoleWorkgroup/UserRoleDepartment/WorkgroupRole 等): @@ -2042,20 +2046,20 @@ class FormService(FormServiceBase): return None def _send_workorder_notify(self, order, receiver_uuid, title, content): - """工单指派/状态流转时向用户发送站内信通知(MsgType=2 工单通知)""" + """工单指派/状态流转时向用户发送站内信通知(MsgType=2 工单通知) + + 委托 gvsdsdk MessageManager.SendMessage,统一站内信写入入口。 + """ try: - from gvsdsdk.models import Message as _WoMsg - _WoMsg.objects.create( - MessageUUID=uuid.uuid4().bytes, - SenderUUID=self._current_user_uuid_bytes(), - ReceiverUUID=receiver_uuid, - Title=title, + from gvsdsdk.auth.managers import MessageManager + MessageManager.SendMessage( + self._current_user_uuid_bytes(), + receiver_uuid, + title, Content=content, MsgType=2, - IsRead=0, RelatedUUID=str(getattr(order, 'OrderUUID', '') or ''), RelatedType='WorkOrder', - CreateTime=datetime.datetime.utcnow(), ) except Exception: logger.warning( @@ -2387,16 +2391,12 @@ class FormService(FormServiceBase): if table_name == 'UserRoleDepartment': from gvsdsdk.auth.managers import PositionManager PositionManager.ClearUrdPositions(getattr(obj, 'UserRoleDepartmentUUID', None)) - # UserRole 删除时级联清理关联表(部门/工作组归属 + 职务子表),防孤儿数据 + # UserRole 删除时级联清理关联表(部门/工作组归属 + 职务子表),防孤儿数据(委托 SDK) if table_name == 'UserRole': _ur_pk = getattr(obj, 'UserRoleUUID', None) if _ur_pk: - from gvsdsdk.auth.managers import PositionManager - _cascade_urds = list(UserRoleDepartment.objects.filter(UserRoleUUID=_ur_pk)) - for _u in _cascade_urds: - PositionManager.ClearUrdPositions(_u.UserRoleDepartmentUUID) - UserRoleDepartment.objects.filter(UserRoleUUID=_ur_pk).delete() - UserRoleWorkgroup.objects.filter(UserRoleUUID=_ur_pk).delete() + from gvsdsdk.auth.managers import RoleManager + RoleManager.RemoveUserRoleRelations(_ur_pk) try: self._DeleteObject(obj) except IntegrityError as e: diff --git a/gvsds/apps/gerp/management/commands/sync_apps.py b/gvsds/apps/gerp/management/commands/sync_apps.py index f8dd11a6..47cc98ca 100644 --- a/gvsds/apps/gerp/management/commands/sync_apps.py +++ b/gvsds/apps/gerp/management/commands/sync_apps.py @@ -36,7 +36,6 @@ class Command(BaseCommand): from gvsds.apps.gerp.app_manifests import ( InjectAppMenus, ListAppManifests, RemoveAppMenus, SyncAppManifests, ) - from gvsds.apps.gerp.models import App synced = SyncAppManifests() self.stdout.write(self.style.SUCCESS(f'已同步 {len(synced)} 个应用:{", ".join(synced)}')) @@ -55,10 +54,10 @@ class Command(BaseCommand): # 默认模式:按安装状态收敛菜单(安装注入 / 未安装删除) injected, removed = [], [] for manifest in ListAppManifests(): - app = App.objects.filter(AppCode=manifest.AppCode).first() + from gvsds.apps.gerp.app_manager import AppManager + app = AppManager.GetAppByCode(manifest.AppCode) if app is None: continue - from gvsds.apps.gerp.app_manager import AppManager if AppManager._AnyEnabledInstall(app.AppUUID): if manifest.Menus: InjectAppMenus(manifest.AppCode) diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py index bd18b91b..2e8bf43f 100644 --- a/gvsds/apps/gerp/views/app.py +++ b/gvsds/apps/gerp/views/app.py @@ -21,6 +21,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from gvsdsdk.auth.admin_service import IsAdmin +from gvsdsdk.auth.managers import CompanyManager, RoleManager from gvsdsdk.auth.permissions import IsAdminUser from gvsdsdk.model_utils import UUIDObj @@ -167,6 +168,35 @@ def _ResolveConfigCompany(request, company_uuid_str): return None, '无权管理该公司的配置' +def _ResolveBindingCompany(request, company_uuid_str): + """解析虚拟职务绑定目标公司并校验权限(绑定按公司隔离) + + 规则: + - 不传公司 UUID:回落第一个可管理公司(公司管理员即本公司) + - 传公司 UUID :租户管理员(scope all/tenant)可任意;公司管理员仅授权公司 + - 拒绝落空(None=全局行):AppVirtualBinding 的 CompanyUUID=None 全局行只对 + "无公司角色"可见性生效,并非公司的租户默认绑定层——配置页语义上 + "虚拟职务绑定必须落在具体公司上",避免把数据写进误导性全局范围。 + + Returns: + (company_bytes, err);company_bytes 恒非空(保证公司隔离),err 非空表示拒绝 + """ + manageable, scope = AppManager.GetManageableCompanies(request) + if not company_uuid_str: + if manageable: + return manageable[0], None + return None, '您没有可管理的公司' + target = _ParseUUIDList([company_uuid_str]) + if not target: + return None, '无效的公司 UUID' + if scope in ('all', 'tenant'): + return target[0], None + manageable_set = {c.hex() for c in manageable} + if target[0].hex() in manageable_set: + return target[0], None + return None, '无权管理该公司的虚拟职务绑定' + + class AppMarketView(ELTAuthenticatedAPIView): """应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。""" @@ -226,8 +256,7 @@ class AppManageCompaniesView(ELTAuthenticatedAPIView): ) rows = [] for c in companies: - from gvsdsdk.models import Company as SdkCompany - company = SdkCompany.objects.filter(CompanyUUID=c).first() + company = CompanyManager.GetCompanyByUUID(c) if company is None: continue row = { @@ -242,9 +271,7 @@ class AppManageCompaniesView(ELTAuthenticatedAPIView): roles = [] tenant_bytes = _GetTenantBytes(request) if tenant_bytes: - from gvsdsdk.models import Role as SdkRole - for r in SdkRole.objects.filter( - TenantUUID=tenant_bytes, RoleStatus=1).order_by('RoleName'): + for r in RoleManager.GetRolesForTenant(tenant_bytes, RoleStatus=1): roles.append({'RoleUUID': UUIDObj(r.RoleUUID).str, 'RoleName': r.RoleName}) return Response({'code': 0, 'data': {'Scope': scope, 'Companies': rows, 'Roles': roles}}) @@ -472,10 +499,13 @@ class AppSetStrictVisibleView(ELTAuthenticatedAPIView): class AppVirtualMappingView(ELTAuthenticatedAPIView): """管理员:应用虚拟职务↔真实职务映射(应用配置页读取/保存) - GET /API/App/VirtualMapping?AppUUID=xxx + GET /API/App/VirtualMapping?AppUUID=xxx[&CompanyUUID=yyy] - 返回应用虚拟职务列表(含已绑定真实职务 UUID)+ 当前租户全部职务(选择用) - POST /API/App/VirtualMapping — Body: {AppUUID, Mappings: [{AppVirtualPositionUUID, PositionUUIDs}]} - - 重写各虚拟职务的真实职务绑定(拥有被绑真实职务的用户看到对应虚拟职务声明的菜单) + - CompanyUUID 缺省回落第一个可管理公司(公司管理员即本公司)——绑定按公司隔离, + 无"租户默认"层,杜绝把绑定写进 CompanyUUID=None 全局行的误导 + POST /API/App/VirtualMapping — Body: {AppUUID, Mappings: [...], CompanyUUID?} + - 重写指定公司的虚拟职务绑定(拥有被绑真实职务的用户看到对应虚拟职务声明的菜单) + - CompanyUUID 缺省回落第一个可管理公司(公司管理员即本公司) """ permission_classes = [IsAuthenticated, IsAdminUser] @@ -484,12 +514,15 @@ class AppVirtualMappingView(ELTAuthenticatedAPIView): app_uuid = (request.query_params.get('AppUUID') or '').strip() if not app_uuid: return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) - # 公司范围:query 参数 CompanyUUID 优先(配置页切换公司),缺省当前公司 + # 公司范围:query 参数 CompanyUUID 优先(配置页切换公司),缺省回落可管理公司 company_str = (request.query_params.get('CompanyUUID') or '').strip() + company_bytes, err = _ResolveBindingCompany(request, company_str) + if err: + return Response({'code': 403, 'msg': err}, status=403) data = AppManager.GetVirtualMapping( app_uuid, TenantUUID=_GetTenantBytes(request), - CompanyUUID=company_str or _GetCompanyBytes(request), + CompanyUUID=company_bytes, ) return Response({'code': 0, 'data': data}) @@ -497,11 +530,17 @@ class AppVirtualMappingView(ELTAuthenticatedAPIView): app_uuid = (request.data.get('AppUUID') or '').strip() if not app_uuid: return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + # 公司范围:显式 CompanyUUID 优先(配置页切换公司后保存),缺省回落可管理公司; + # 修复原实现硬编码写"当前登录角色公司"导致切公司保存写错公司的跨公司隔离漏洞 + company_str = (request.data.get('CompanyUUID') or '').strip() + company_bytes, err = _ResolveBindingCompany(request, company_str) + if err: + return Response({'code': 403, 'msg': err}, status=403) ok, msg = AppManager.SetVirtualMapping( app_uuid, request.data.get('Mappings') or [], TenantUUID=_GetTenantBytes(request), - CompanyUUID=_GetCompanyBytes(request), + CompanyUUID=company_bytes, ) if not ok: return Response({'code': 1, 'msg': msg}, status=400) @@ -511,10 +550,10 @@ class AppVirtualMappingView(ELTAuthenticatedAPIView): class AppVirtualAutoBindView(ELTAuthenticatedAPIView): """管理员:一键绑定虚拟职务↔真实职务(按名称自动匹配) - POST /API/App/VirtualAutoBind — Body: {AppUUID} + POST /API/App/VirtualAutoBind — Body: {AppUUID, CompanyUUID?} 前提:应用提供的通用部门与职务模板已同步(SyncPositionTemplates), 否则同名的真实职务不存在会记为 Unmatched。 - 绑定按当前公司范围写入(与 VirtualMapping 保存一致)。 + 绑定按公司范围写入(与 VirtualMapping 保存一致:缺省回落可管理公司)。 """ permission_classes = [IsAuthenticated, IsAdminUser] @@ -526,9 +565,11 @@ class AppVirtualAutoBindView(ELTAuthenticatedAPIView): tenant_bytes = _GetTenantBytes(request) if not tenant_bytes: return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400) - # 公司范围:显式 CompanyUUID 优先,缺省当前公司(对齐 VirtualMapping 读取范围) + # 公司范围:显式 CompanyUUID 优先,缺省回落可管理公司(对齐 VirtualMapping) company_str = (request.data.get('CompanyUUID') or '').strip() - company_bytes = _ToBytes(company_str) if company_str else _GetCompanyBytes(request) + company_bytes, err = _ResolveBindingCompany(request, company_str) + if err: + return Response({'code': 403, 'msg': err}, status=403) ok, msg, stats = AppManager.AutoBindVirtualPositions( app_uuid, TenantUUID=tenant_bytes, CompanyUUID=company_bytes) if not ok: diff --git a/gvsds/apps/gerp/views/gitea.py b/gvsds/apps/gerp/views/gitea.py index 65828520..01de0ff9 100644 --- a/gvsds/apps/gerp/views/gitea.py +++ b/gvsds/apps/gerp/views/gitea.py @@ -56,13 +56,12 @@ def _GetUserBytes(request): def _UserCompanyNames(user_bytes): - """返回用户所属公司名称列表(经 UserRole 关联)。""" - from gvsdsdk.models import UserRole as SdkUserRole, Company as SdkCompany - roles = SdkUserRole.objects.filter(UserUUID=user_bytes).values_list('CompanyUUID', flat=True) - comp_uuids = [c for c in roles if c] + """返回用户所属公司名称列表(经 UserRole 关联,委托 SDK Manager)。""" + from gvsdsdk.auth.managers import RoleManager, CompanyManager + comp_uuids = RoleManager.GetUserCompanyUUIDs(user_bytes) if not comp_uuids: return [] - return list(SdkCompany.objects.filter(CompanyUUID__in=comp_uuids).values_list('CompanyName', flat=True)) + return CompanyManager.GetCompanyNames(comp_uuids) def _CheckCompanyAllowed(user_bytes): diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/views/mail.py index f6ddbf51..33a37b06 100644 --- a/gvsds/apps/gerp/views/mail.py +++ b/gvsds/apps/gerp/views/mail.py @@ -178,13 +178,12 @@ def _GetUserBytes(request): def _UserCompanyNames(user_bytes): - """返回用户所属公司名称列表(经 UserRole 关联)。""" - from gvsdsdk.models import UserRole as SdkUserRole, Company as SdkCompany - roles = SdkUserRole.objects.filter(UserUUID=user_bytes).values_list('CompanyUUID', flat=True) - comp_uuids = [c for c in roles if c] + """返回用户所属公司名称列表(经 UserRole 关联,委托 SDK Manager)。""" + from gvsdsdk.auth.managers import RoleManager, CompanyManager + comp_uuids = RoleManager.GetUserCompanyUUIDs(user_bytes) if not comp_uuids: return [] - return list(SdkCompany.objects.filter(CompanyUUID__in=comp_uuids).values_list('CompanyName', flat=True)) + return CompanyManager.GetCompanyNames(comp_uuids) def _CheckCompanyAllowed(user_bytes): diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py index 81d30ea6..b66be414 100644 --- a/gvsds/apps/gerp/views/menu.py +++ b/gvsds/apps/gerp/views/menu.py @@ -16,7 +16,7 @@ import uuid as _uuid import logging from rest_framework.response import Response -from gvsdsdk.auth.managers import MenuManager +from gvsdsdk.auth.managers import MenuManager, RoleManager from . import ELTAuthenticatedAPIView logger = logging.getLogger(__name__) @@ -166,15 +166,12 @@ class MenuListCompatView(ELTAuthenticatedAPIView): # RAM 角色粒度:配置了 RAM 策略(存在 app 级 view 授权)的 App, # 仅对获得授权的角色可见;无策略的 App 保持全员可见(向后兼容)。 try: - from django.db.models import Q as _DJQ - from ..models import App from ..app_manager import AppManager, _GetUserPositionUUIDs from ..app_manifests import GetAppManifest # 当前租户 UUID:与 ResolveRoleContext 一致,取自当前角色 Role.TenantUUID current_tenant_uuid = None if role_uuid_bytes: - from gvsdsdk.models import Role as _SdkRole - _role_obj = _SdkRole.objects.filter(RoleUUID=role_uuid_bytes).first() + _role_obj = RoleManager.GetRoleByUUID(role_uuid_bytes) if _role_obj is not None: current_tenant_uuid = _role_obj.TenantUUID @@ -221,20 +218,12 @@ class MenuListCompatView(ELTAuthenticatedAPIView): ram_policy_apps = set() ram_granted_apps = set() try: - from gvsdsdk.models import RAMGrant as _SdkRAMGrant from gvsdsdk.auth import RAMManager as _SdkRAMManager _tenant_bytes = _ToUUIDBytes(current_tenant_uuid) _company_bytes = _ToUUIDBytes(current_company) - ram_qs = _SdkRAMGrant.objects.filter( - ResourceType='app', Action='view', GrantStatus=1, - ) - if _tenant_bytes is not None: - ram_qs = ram_qs.filter( - _DJQ(TenantUUID__isnull=True) | _DJQ(TenantUUID=_tenant_bytes)) - if _company_bytes is not None: - ram_qs = ram_qs.filter( - _DJQ(CompanyUUID__isnull=True) | _DJQ(CompanyUUID=_company_bytes)) - ram_policy_apps = set(ram_qs.values_list('ResourceUUID', flat=True)) + ram_policy_apps = set(_SdkRAMManager.ListPolicyResourceUUIDs( + 'app', 'view', _tenant_bytes, _company_bytes, + )) if ram_policy_apps and role_uuid_bytes: ram_granted_apps = set(_SdkRAMManager.ListResourcesForPrincipal( role_uuid_bytes, 'app', 'view', @@ -243,7 +232,7 @@ class MenuListCompatView(ELTAuthenticatedAPIView): except Exception: logger.exception('MenuListCompatView RAM 策略解析失败') - for _app in App.objects.filter(AppStatus=1): + for _app in AppManager.ListEnabledApps(): _app_uuid_bytes = _ToUUIDBytes(_app.AppUUID) if AppManager.CheckAccess( current_tenant_uuid, user.UserUUID, _app.AppCode, diff --git a/gvsds/apps/gerp/views/viewsets.py b/gvsds/apps/gerp/views/viewsets.py index 9a86bf42..46f6ef50 100644 --- a/gvsds/apps/gerp/views/viewsets.py +++ b/gvsds/apps/gerp/views/viewsets.py @@ -15,7 +15,6 @@ from gvsdsdk.models import ( PositionTemplatePermission as SdkPositionTemplatePermission, CompanyInfo as SdkCompanyInfo, DepartmentSMTM as SdkDepartmentSMTM, - UserRoleDepartment as SdkUserRoleDepartment, Workgroup as SdkWorkgroup, SimulatedTrigger as SdkSimulatedTrigger, CashReceiptsPaymentsLedger as SdkCashReceiptsPaymentsLedger, @@ -187,11 +186,8 @@ class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet): dept = DepartmentManager.GetDepartmentByUUID(dept_bytes) if dept and UUIDObj(dept.CompanyUUID).bytes != current_company: return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) - smtm, created = SdkDepartmentSMTM.objects.update_or_create( - DepartmentUUID=dept_bytes, - UserUUID=user_bytes, - defaults={'PersonInChargeStatus': status_val} - ) + from gvsdsdk.auth.managers import DepartmentManager + smtm = DepartmentManager.SetPersonInCharge(dept_bytes, user_bytes, status_val) return Response({'code': 0, 'msg': '设置成功', 'data': DepartmentSMTMSerializer(smtm).data}) @@ -352,16 +348,14 @@ class UserRoleViewSet(CompanyScopedMixin, viewsets.ModelViewSet): except Exception: user_uuid_map[str(u.UserUUID)] = u.UserName if role_uuid_map: - for r in SdkRole.objects.filter(RoleUUID__in=list(role_uuid_map.keys())): - try: - role_uuid_map[r.RoleUUID.bytes] = r.RoleName - except Exception: - role_uuid_map[str(r.RoleUUID)] = r.RoleName + from gvsdsdk.auth.managers import RoleManager + role_uuid_map.update(RoleManager.GetRoleNameMap(list(role_uuid_map.keys()))) # 部门归属以 UserRoleDepartment 为准(UserRole.DeptUUID 已废弃清空): # 批量取"用户-角色对 → 首个部门",供部门树浏览面板分组 urd_dept_map = {} try: - urd_rows = SdkUserRoleDepartment.objects.filter(UserRoleUUID__in=[ + from gvsdsdk.auth.managers import RoleManager + urd_rows = RoleManager.GetUserRoleDepartmentsForUserRoles([ ur.UserRoleUUID for ur in page if ur.UserRoleUUID ]) for u in urd_rows: @@ -484,17 +478,16 @@ class RolePermissionViewSet(CompanyScopedMixin, viewsets.ModelViewSet): role = RoleManager.GetRoleByUUID(data['RoleUUID']) if role and getattr(role, 'TenantUUID_id', None) != current_tenant and getattr(role, 'TenantUUID', None) != current_tenant: return Response({'code': 403, 'msg': '无权操作:目标角色不属于当前租户'}, status=403) - perm = SdkPermission.objects.filter(PermUUID=data['PermUUID']).first() + perm = RoleManager.GetPermissionByUUID(data['PermUUID']) if perm and getattr(perm, 'TenantUUID_id', None) != current_tenant and getattr(perm, 'TenantUUID', None) != current_tenant: return Response({'code': 403, 'msg': '无权操作:目标权限不属于当前租户'}, status=403) - existing = SdkRolePermission.objects.filter( - RoleUUID=data['RoleUUID'], - PermUUID=data['PermUUID'], - CompanyUUID=company_uuid, - ).first() + from gvsdsdk.auth.managers import RoleManager + existing = RoleManager.GetRolePermission( + data['RoleUUID'], data['PermUUID'], + CompanyUUID=company_uuid, MatchNull=True, + ) if existing: return Response({'code': 409, 'msg': '关联已存在', 'data': RolePermissionSerializer(existing).data}, status=409) - from gvsdsdk.auth.managers import RoleManager rp = RoleManager.AssignPermission(data['RoleUUID'], data['PermUUID'], company_uuid) return Response({'code': 0, 'msg': '关联成功', 'data': RolePermissionSerializer(rp).data}, status=201) @@ -628,8 +621,8 @@ class FileAssetViewSet(CompanyScopedMixin, viewsets.ModelViewSet): if not tenant_uuid: return qs.none() tb = _U(tenant_uuid).bytes - companies = SdkCompany.objects.filter(TenantUUID=tb).values_list('CompanyUUID', flat=True) - company_bytes = [c.bytes if hasattr(c, 'bytes') else c for c in companies] + from gvsdsdk.auth.managers import CompanyManager + company_bytes = CompanyManager.GetCompanyUUIDs(tb) return qs.filter(CompanyUUID__in=company_bytes) if IsCompanyAdmin(request=self.request, user=user): company_uuid = getattr(self.request, 'current_company_uuid', None) @@ -1052,20 +1045,16 @@ class PositionViewSet(TenantScopedMixin, viewsets.ModelViewSet): elif request.query_params.get('CompanyUUID'): _comp = None try: - _comp = SdkCompany.objects.filter( - CompanyUUID=uuid.UUID(request.query_params['CompanyUUID']).bytes - ).first() + from gvsdsdk.auth.managers import CompanyManager + _comp = CompanyManager.GetCompanyByUUID(request.query_params['CompanyUUID']) except Exception: _comp = None if _comp: if getattr(_comp, 'TenantUUID', None): tenant_uuid = UUIDObj(_comp.TenantUUID).bytes try: - priority_cats = set( - SdkDepartment.objects.filter(CompanyUUID=_comp.CompanyUUID) - .exclude(PositionCategoryUUID__isnull=True) - .values_list('PositionCategoryUUID', flat=True) - ) + from gvsdsdk.auth.managers import DepartmentManager + priority_cats = DepartmentManager.ListCompanyCategoryUUIDs(_comp.CompanyUUID) except Exception: priority_cats = None else: diff --git a/gvsds/apps/mc/views.py b/gvsds/apps/mc/views.py index 65d1b2a8..74bda0d2 100644 --- a/gvsds/apps/mc/views.py +++ b/gvsds/apps/mc/views.py @@ -14,9 +14,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from gvsds.apps.mc.manager import EasyAuthManager -from gvsdsdk.auth import RoleManager - -from gvsds.apps.gerp.models import Company +from gvsdsdk.auth import RoleManager, CompanyManager class UserBindView(APIView): @@ -74,20 +72,14 @@ class UserBindView(APIView): role = RoleManager.GetRoleByName(self.PLAYER_ROLE_NAME) if role is None: return Response({'status': 1, 'msg': f'系统未找到角色:{self.PLAYER_ROLE_NAME}'}) - try: - company = Company.objects.get(CompanyName=self.PLAYER_COMPANY_NAME) - except Company.DoesNotExist: + company = CompanyManager.GetCompanyByName(self.PLAYER_COMPANY_NAME) + if company is None: return Response({'status': 1, 'msg': f'系统未找到公司:{self.PLAYER_COMPANY_NAME}'}) # 7. 检查当前 GVSDS 用户是否已绑定过该游戏账号(防止重复绑定同一玩家) # 允许一个用户绑定多个不同玩家(多对多),但不允许重复绑定同一玩家。 - from gvsdsdk.models import UserRole as _UR - from gvsdsdk.model_utils import UUIDObj as _UUIDObj from gvsds.apps.mc.models import EasyAuth as _EasyAuthModel - _user_role_uuids = list( - _UR.objects.filter(UserUUID=_UUIDObj(gvsds_user_uuid).bytes) - .values_list('UserRoleUUID', flat=True) - ) + _user_role_uuids = RoleManager.ListUserRoleUUIDs(gvsds_user_uuid) if _user_role_uuids and _EasyAuthModel.objects.filter( AuthID=easyauth.AuthID, UserRoleUUID__in=_user_role_uuids, diff --git a/gvsds/apps/payment/models.py b/gvsds/apps/payment/models.py index 5f12277e..f1dc2419 100644 --- a/gvsds/apps/payment/models.py +++ b/gvsds/apps/payment/models.py @@ -67,13 +67,6 @@ class PaymentGatewayConfig(QModel): return '' return _decrypt_field(self.PublicKeyEncrypted) - @classmethod - def FromTenantAndPlatform(cls, tenant_uuid, platform): - try: - return cls.objects.get(TenantUUID=tenant_uuid, Platform=platform, ConfigStatus=1) - except cls.DoesNotExist: - return None - class PaymentTransaction(QModel): """支付交易流水表——SAAS 级统一交易记录 diff --git a/gvsds/apps/stats/tasks.py b/gvsds/apps/stats/tasks.py index 04b9b487..09074c09 100644 --- a/gvsds/apps/stats/tasks.py +++ b/gvsds/apps/stats/tasks.py @@ -47,12 +47,10 @@ def ResetMonthlyStats(self): 建议 beat 调度:每月 1 日 00:00 执行。 """ from gvsdsdk.stats.manager import StatsManager - from gvsdsdk.stats.models import StatConfig total = 0 for tenant_uuid in _GetActiveTenants(): - for config in StatConfig.objects.filter( - TenantUUID=tenant_uuid, AutoReset=True, ConfigStatus=1): + for config in StatsManager.ListAutoResetConfigs(tenant_uuid): try: total += StatsManager.MonthlyReset(tenant_uuid, config.Metric_id) except Exception: @@ -100,10 +98,10 @@ def BuildDailyRankings(self): def _GetActiveTenants(): - """获取所有活跃租户的 UUID 列表(bytes 形式)。""" + """获取所有活跃租户的 UUID 列表(bytes 形式,委托 SDK TenantManager)。""" try: - from gvsdsdk.models import Tenant - return [t.TenantUUID.bytes for t in Tenant.objects.filter(TenantStatus=1)] + from gvsdsdk.auth.managers import TenantManager + return TenantManager.ListActiveTenants() except Exception: logger.warning('[StatsTasks] 获取租户列表失败,返回空列表', exc_info=True) return [] diff --git a/gvsds/celery_utils.py b/gvsds/celery_utils.py index 1d73f6e1..e68ad3c6 100644 --- a/gvsds/celery_utils.py +++ b/gvsds/celery_utils.py @@ -1,119 +1,7 @@ -"""模块:Celery 任务工具装饰器 +"""兼容转发层:Celery 任务装饰器实现已下沉 gvsdsdk.celery_utils。 -提供事务回滚、重试、日志、安全 Decimal 运算等通用装饰器, -统一异步任务的异常处理与可观测性。 - -依赖 Celery(gvsds/celery.py),无 Celery 时装饰器自动降级为同步执行。 - -常用 API: - from gvsds.celery_utils import RollbackOnFailure, RetryOnFailure, LogTask, SafeDecimal - - @App.task - @RollbackOnFailure - @RetryOnFailure(max_retries=3, delay=5) - @LogTask - def CriticalTask(pk): - ... - - @SafeDecimal(default=Decimal('0')) - def AggregateStats(qs): - ... +新代码请直接 `from gvsdsdk.celery_utils import ...`。 """ -import logging -import functools -from decimal import Decimal - -logger = logging.getLogger(__name__) - -try: - from celery import shared_task - _HAS_CELERY = True -except ImportError: - _HAS_CELERY = False - shared_task = None - - -def _IsCeleryTask(func): - """判断函数是否为 Celery 任务对象。""" - return _HAS_CELERY and hasattr(func, 'apply_async') - - -def RollbackOnFailure(func): - """事务回滚装饰器:任务异常时自动回滚当前事务。 - - 适用于写操作任务,防止部分写入导致数据不一致。 - """ - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception: - try: - from django.db import transaction - transaction.set_rollback(True) - logger.warning('[CeleryUtils] 任务异常,已标记事务回滚: %s', func.__name__, exc_info=True) - except Exception: - pass - raise - return wrapper - - -def RetryOnFailure(max_retries=3, delay=5, exceptions=(Exception,)): - """重试装饰器:任务异常时自动重试。 - - 参数: - max_retries 最大重试次数 - delay 重试间隔秒 - exceptions 触发重试的异常类型 - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - last_exc = None - for attempt in range(max_retries + 1): - try: - return func(*args, **kwargs) - except exceptions as e: - last_exc = e - if attempt < max_retries: - logger.info('[CeleryUtils] 任务重试 %d/%d: %s', attempt + 1, max_retries, func.__name__) - import time - time.sleep(delay) - else: - logger.error('[CeleryUtils] 任务重试耗尽: %s', func.__name__, exc_info=True) - raise last_exc - return wrapper - return decorator - - -def LogTask(func): - """日志装饰器:记录任务开始/成功/失败。""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - name = func.__name__ - logger.info('[CeleryUtils] 任务开始: %s', name) - try: - result = func(*args, **kwargs) - logger.info('[CeleryUtils] 任务成功: %s', name) - return result - except Exception: - logger.error('[CeleryUtils] 任务失败: %s', name, exc_info=True) - raise - return wrapper - - -def SafeDecimal(default=Decimal('0')): - """安全 Decimal 运算装饰器:异常时返回 default。 - - 适用于聚合统计任务,单条脏数据不中断整体计算。 - """ - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except (ArithmeticError, TypeError, ValueError): - logger.warning('[CeleryUtils] Decimal 运算异常,返回默认值: %s', func.__name__, exc_info=True) - return default - return wrapper - return decorator +from gvsdsdk.celery_utils import ( # noqa: F401 + RollbackOnFailure, RetryOnFailure, LogTask, SafeDecimal, +) diff --git a/gvsds/common/DateTime.py b/gvsds/common/DateTime.py index 00e9a6c5..f569e891 100644 --- a/gvsds/common/DateTime.py +++ b/gvsds/common/DateTime.py @@ -1,24 +1,4 @@ -import datetime - - -def UTCNow(): - return datetime.datetime.utcnow() - - -def ELTTokenExpiry(days=30): - return UTCNow() + datetime.timedelta(days=days) - - -def FormatDateTime(dt): - if dt is None: - return None - if isinstance(dt, datetime.datetime): - return dt.isoformat() - return str(dt) - - -def IsTokenExpired(end_time): - if end_time is None: - return True - naive = end_time.replace(tzinfo=None) if hasattr(end_time, 'tzinfo') else end_time - return UTCNow() > naive +"""兼容转发层:时间工具实现已下沉 gvsdsdk.datetime_utils。""" +from gvsdsdk.datetime_utils import ( # noqa: F401 + UTCNow, ELTTokenExpiry, FormatDateTime, IsTokenExpired, +) diff --git a/gvsds/common/Naming.py b/gvsds/common/Naming.py index 79f06ad3..5b7ad311 100644 --- a/gvsds/common/Naming.py +++ b/gvsds/common/Naming.py @@ -1,124 +1,9 @@ -import re +"""兼容转发层:命名转换实现已收敛到 gvsdsdk.model_base(单一源)。 -ABBREVIATIONS = { - 'uuid': 'UUID', - 'id': 'ID', - 'ip': 'IP', - 'url': 'URL', - 'uri': 'URI', - 'http': 'HTTP', - 'https': 'HTTPS', - 'ssl': 'SSL', - 'tls': 'TLS', - 'dns': 'DNS', - 'api': 'API', - 'sql': 'SQL', - 'db': 'DB', - 'orm': 'ORM', - 'json': 'JSON', - 'xml': 'XML', - 'html': 'HTML', - 'css': 'CSS', - 'js': 'JS', - 'php': 'PHP', - 'jwt': 'JWT', - 'otp': 'OTP', - 'totp': 'TOTP', - '2fa': '2FA', - 'mfa': 'MFA', - 'sso': 'SSO', - 'saml': 'SAML', - 'ldap': 'LDAP', - 'rbac': 'RBAC', - 'abac': 'ABAC', - 'acl': 'ACL', - 'crb': 'CRB', - 'ugm': 'UGM', - 'gerp': 'GERP', - 'sve': 'SVE', - 'ccgo': 'CCGO', - 'smtm': 'SMTM', - 'cvss': 'CVSS', - 'sha': 'SHA', - 'sha1': 'SHA1', - 'sha256': 'SHA256', - 'md5': 'MD5', - 'hmac': 'HMAC', - 'aes': 'AES', - 'rsa': 'RSA', - 'crc': 'CRC', - 'eof': 'EOF', - 'io': 'IO', - 'os': 'OS', - 'cpu': 'CPU', - 'gpu': 'GPU', - 'ram': 'RAM', - 'rom': 'ROM', - 'vm': 'VM', - 'vpn': 'VPN', - 'lan': 'LAN', - 'wan': 'WAN', - 'mac': 'MAC', - 'tcp': 'TCP', - 'udp': 'UDP', - 'ftp': 'FTP', - 'smtp': 'SMTP', - 'pop': 'POP', - 'imap': 'IMAP', - 'ssh': 'SSH', - 'scp': 'SCP', - 'rpc': 'RPC', - 'rest': 'REST', - 'ws': 'WS', - 'wss': 'WSS', - 'sdk': 'SDK', - 'cli': 'CLI', - 'gui': 'GUI', - 'ui': 'UI', - 'ux': 'UX', - 'crUD': 'CRUD', - 'dto': 'DTO', - 'vo': 'VO', - 'dao': 'DAO', - 'po': 'PO', - 'bo': 'BO', - 'vo': 'VO', - 'do': 'DO', - 'vo': 'VO', - 'mvc': 'MVC', - 'mvp': 'MVP', - 'mvvm': 'MVVM', - 'orm': 'ORM', - 'cdn': 'CDN', - 'dns': 'DNS', - 'cicd': 'CICD', - 'devops': 'DevOps', - 'saas': 'SaaS', - 'paas': 'PaaS', - 'iaas': 'IaaS', - 'onprem': 'OnPrem', -} - - -def SnakeToPascal(name): - if not name: - return name - parts = name.split('_') - result = [] - for part in parts: - lower = part.lower() - if lower in ABBREVIATIONS: - result.append(ABBREVIATIONS[lower]) - else: - result.append(part.capitalize()) - return ''.join(result) - - -_PASCAL_TO_SNAKE_RE = re.compile(r'(?<=[a-z0-9])[A-Z]|(?<=[A-Z])[A-Z][a-z]') - - -def PascalToSnake(name): - if not name: - return name - result = _PASCAL_TO_SNAKE_RE.sub(lambda m: '_' + m.group(0).lower(), name) - return result.lower() +注意:转换函数以别名形式导出,保持既有 PascalCase 名称不变。 +""" +from gvsdsdk.model_base import ( # noqa: F401 + ABBREVIATIONS, + snake_to_pascal as SnakeToPascal, + pascal_to_snake as PascalToSnake, +) diff --git a/gvsds/common/String.py b/gvsds/common/String.py index 62d599f8..a208f14a 100644 --- a/gvsds/common/String.py +++ b/gvsds/common/String.py @@ -1,9 +1,4 @@ -def TruncateString(s, max_length=50, suffix='...'): - if not s or len(s) <= max_length: - return s - return s[:max_length - len(suffix)] + suffix - - -def PascalCaseToDictKey(pascal_key): - from gvsds.common.Naming import PascalToSnake - return PascalToSnake(pascal_key) +"""兼容转发层:字符串工具实现已下沉 gvsdsdk.string_utils。""" +from gvsdsdk.string_utils import ( # noqa: F401 + TruncateString, PascalCaseToDictKey, +) diff --git a/gvsds/common/UUID.py b/gvsds/common/UUID.py index a41a1cf1..c51f21a4 100644 --- a/gvsds/common/UUID.py +++ b/gvsds/common/UUID.py @@ -1,85 +1,9 @@ -import uuid +"""兼容转发层:UUID 工具已收敛到 gvsdsdk.model_utils(单一源)。 - -class UUIDObj: - __slots__ = ('_value',) - - def __init__(self, value=None): - if value is None: - self._value = uuid.uuid4() - elif isinstance(value, UUIDObj): - self._value = value._value - elif isinstance(value, uuid.UUID): - self._value = value - elif isinstance(value, bytes): - if len(value) == 16: - self._value = uuid.UUID(bytes=value) - else: - raise ValueError(f'Invalid UUID bytes length: {len(value)}, expected 16') - elif isinstance(value, str): - self._value = uuid.UUID(value) - else: - raise TypeError(f'Cannot convert {type(value).__name__} to UUIDObj') - - @property - def Str(self): - return str(self._value) - - @property - def Bytes(self): - return self._value.bytes - - @property - def UUID(self): - return self._value - - def __str__(self): - return self.Str - - def __repr__(self): - return f'UUIDObj({self.Str})' - - def __eq__(self, other): - if isinstance(other, UUIDObj): - return self._value == other._value - if isinstance(other, uuid.UUID): - return self._value == other - if isinstance(other, str): - return self.Str == other - if isinstance(other, bytes) and len(other) == 16: - return self.Bytes == other - return NotImplemented - - def __hash__(self): - return hash(self._value) - - def __bool__(self): - return True - - -def NewUUID(): - return UUIDObj() - - -def NewUUIDBytes(): - return uuid.uuid4().bytes - - -def NewUUIDStr(): - return str(uuid.uuid4()) - - -def UUIDToBytes(value): - return UUIDObj(value).Bytes - - -def UUIDToStr(value): - return UUIDObj(value).Str - - -def BytesToUUIDStr(value): - return str(uuid.UUID(bytes=value)) - - -def StrToUUIDBytes(value): - return uuid.UUID(value).bytes +差异说明:旧实现 `UUIDObj(None)` 会生成随机 UUID,新实现返回空值对象 +(`.str/.bytes` 为 None);`NewUUID()` 仍是"生成随机 UUID"的显式入口。 +""" +from gvsdsdk.model_utils import ( # noqa: F401 + UUIDObj, NewUUID, NewUUIDBytes, NewUUIDStr, + UUIDToBytes, UUIDToStr, BytesToUUIDStr, StrToUUIDBytes, +) diff --git a/gvsds/exception_handler.py b/gvsds/exception_handler.py index f2ca6085..7a186767 100644 --- a/gvsds/exception_handler.py +++ b/gvsds/exception_handler.py @@ -1,54 +1,5 @@ -import logging -from rest_framework.views import exception_handler -from rest_framework.response import Response -from rest_framework import status -from django.conf import settings +"""兼容转发层:DRF 统一异常处理实现已下沉 gvsdsdk.exception_handler。 -logger = logging.getLogger(__name__) - - -def custom_exception_handler(exc, context): - # 始终记录异常堆栈(走 logging,日志落盘可审计,替代 print) - view = context.get('view', None) if context else None - view_name = view.__class__.__name__ if view else 'Unknown' - logger.error('[EXCEPTION] %s: %s', view_name, exc, exc_info=True) - - response = exception_handler(exc, context) - - if response is not None: - data = { - 'code': response.status_code, - 'msg': str(exc) if settings.DEBUG else '请求处理失败', - 'data': None, - } - if isinstance(response.data, dict): - if 'detail' in response.data: - # 401/403 的 detail(如 "身份认证信息未提供"/"Token 已过期")对前端是 - # 必要的业务信息,不属于敏感堆栈,即使在生产环境也直接暴露。 - if response.status_code in (status.HTTP_401_UNAUTHORIZED, - status.HTTP_403_FORBIDDEN): - data['msg'] = str(response.data['detail']) - else: - data['msg'] = str(response.data['detail']) if settings.DEBUG else '请求处理失败' - elif 'msg' in response.data: - data['msg'] = response.data['msg'] - else: - if settings.DEBUG: - errors = [] - for field, messages in response.data.items(): - if isinstance(messages, list): - errors.extend([f"{field}: {m}" for m in messages]) - else: - errors.append(f"{field}: {messages}") - data['msg'] = '; '.join(errors) if errors else str(exc) - else: - data['msg'] = '请求参数错误' - response.data = data - else: - # 未被 DRF 捕获的异常(如 Django FieldError、AttributeError 等) - response = Response( - {'code': 500, 'msg': str(exc) if settings.DEBUG else '服务器内部错误', 'data': None}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - return response +settings.REST_FRAMEWORK['EXCEPTION_HANDLER'] 仍指向本模块路径,行为不变。 +""" +from gvsdsdk.exception_handler import custom_exception_handler # noqa: F401 diff --git a/gvsds/ip_filter.py b/gvsds/ip_filter.py index f8e141c4..788a7a47 100644 --- a/gvsds/ip_filter.py +++ b/gvsds/ip_filter.py @@ -1,117 +1,13 @@ -"""模块:IP 访问控制中间件 +"""兼容转发层:IP 访问控制实现已下沉 gvsdsdk.ip_filter。 -支持三种模式:production(仅白名单)、whitelist(白名单优先)、blacklist(黑名单优先)。 -支持 CIDR 网段、IPv4/IPv6、X-Real-IP/X-Forwarded-For 穿透、免过滤路径。 - -配置(app.ini [ip_filter] 段或环境变量 IP_FILTER_*): - MODE = production | whitelist | blacklist - WHITELIST = 127.0.0.1,10.0.0.0/8 - BLACKLIST = 1.2.3.4,5.6.7.0/24 - EXEMPT_PATHS = /health,/API/Auth - TRUSTED_PROXIES = 127.0.0.1 # 信任的代理 IP,用于正确解析 X-Forwarded-For +本模块仅做两件事: + 1. 注入 app.ini 配置提供者(SDK 不反向依赖业务层配置); + 2. re-export 既有符号,保持 settings.MIDDLEWARE 路径与测试导入不变。 """ -import ipaddress -import logging - from gvsds.app_config import get as _app_get, getlist as _app_getlist +from gvsdsdk.config import SetConfigProvider +from gvsdsdk.ip_filter import ( # noqa: F401 + IPFilterMiddleware, _ParseIPList, _GetClientIP, +) -logger = logging.getLogger(__name__) - - -def _ParseIPList(raw): - """解析 IP/CIDR 列表为 networks。""" - nets = [] - for item in raw: - item = item.strip() - if not item: - continue - try: - if '/' in item: - nets.append(ipaddress.ip_network(item, strict=False)) - else: - nets.append(ipaddress.ip_network(item + '/32', strict=False) - if ':' not in item else - ipaddress.ip_network(item + '/128', strict=False)) - except ValueError: - logger.warning('[IPFilter] 无效的 IP/CIDR: %s', item) - return nets - - -def _GetClientIP(request, trusted_proxies): - """获取真实客户端 IP,穿透可信代理。""" - xff = request.META.get('HTTP_X_FORWARDED_FOR', '') - xri = request.META.get('HTTP_X_REAL_IP', '') - remote = request.META.get('REMOTE_ADDR', '') - - if xff: - chain = [ip.strip() for ip in xff.split(',') if ip.strip()] - if chain: - if trusted_proxies: - # 从右往左找到第一个不在可信代理链中的 IP - for ip in reversed(chain): - try: - addr = ipaddress.ip_address(ip) - if not any(addr in net for net in trusted_proxies): - return ip - except ValueError: - continue - return chain[0] - if xri: - return xri.split(',')[0].strip() - return remote - - -class IPFilterMiddleware: - """IP 白黑名单中间件。""" - - def __init__(self, get_response): - self.get_response = get_response - # 默认 disabled(安全默认值权衡:避免漏配即拒绝服务)。 - # 生产环境应显式配置 MODE=production/whitelist 并挂载中间件(见 app.ini [middleware])。 - self.mode = _app_get('ip_filter', 'MODE', fallback='disabled').lower() - self.whitelist = _ParseIPList(_app_getlist('ip_filter', 'WHITELIST', fallback=[])) - self.blacklist = _ParseIPList(_app_getlist('ip_filter', 'BLACKLIST', fallback=[])) - self.exempt_paths = tuple(p.strip() for p in _app_getlist('ip_filter', 'EXEMPT_PATHS', fallback=['/health', '/API/Auth'])) - self.trusted_proxies = _ParseIPList(_app_getlist('ip_filter', 'TRUSTED_PROXIES', fallback=['127.0.0.1/32'])) - - def __call__(self, request): - if self.mode == 'disabled': - return self.get_response(request) - - path = request.path - if path.startswith(self.exempt_paths): - return self.get_response(request) - - client_ip = _GetClientIP(request, self.trusted_proxies) - request.client_ip = client_ip - - try: - addr = ipaddress.ip_address(client_ip) - except ValueError: - logger.warning('[IPFilter] 无法解析客户端 IP: %s', client_ip) - return self._deny(client_ip, path, 'invalid ip') - - if self.mode == 'blacklist': - if any(addr in net for net in self.blacklist): - return self._deny(client_ip, path, 'blacklisted') - return self.get_response(request) - - if self.mode == 'whitelist': - if self.whitelist and not any(addr in net for net in self.whitelist): - return self._deny(client_ip, path, 'not in whitelist') - return self.get_response(request) - - if self.mode == 'production': - # production 模式:仅白名单 + 黑名单豁免 - if any(addr in net for net in self.blacklist): - return self._deny(client_ip, path, 'blacklisted') - if self.whitelist and not any(addr in net for net in self.whitelist): - return self._deny(client_ip, path, 'not in whitelist') - return self.get_response(request) - - return self.get_response(request) - - def _deny(self, ip, path, reason): - logger.warning('[IPFilter] 拒绝访问: ip=%s path=%s reason=%s', ip, path, reason) - from django.http import JsonResponse - return JsonResponse({'code': 403, 'msg': 'IP 访问被拒绝'}, status=403) +SetConfigProvider(_app_get, _app_getlist) diff --git a/gvsds/json_encoder.py b/gvsds/json_encoder.py index 4ed63bdd..3a07c623 100644 --- a/gvsds/json_encoder.py +++ b/gvsds/json_encoder.py @@ -1,19 +1,5 @@ -"""GVS 自定义 JSON 编码器与渲染器——处理 UUIDObj 等非标准类型的序列化""" +"""兼容转发层:JSON 编码器/渲染器实现已下沉 gvsdsdk.json_encoder。 -from rest_framework.renderers import JSONRenderer as _DRFJSONRenderer -from rest_framework.utils.encoders import JSONEncoder as _DRFJSONEncoder -from gvsdsdk.model_utils import UUIDObj - - -class JSONEncoder(_DRFJSONEncoder): - """扩展 DRF 的 JSONEncoder,支持 UUIDObj 序列化""" - - def default(self, obj): - if isinstance(obj, UUIDObj): - return obj.str - return super().default(obj) - - -class JSONRenderer(_DRFJSONRenderer): - """使用自定义 JSONEncoder 的渲染器""" - encoder_class = JSONEncoder +新代码请直接 `from gvsdsdk.json_encoder import ...`。 +""" +from gvsdsdk.json_encoder import JSONEncoder, JSONRenderer # noqa: F401 diff --git a/gvsds/money.py b/gvsds/money.py index 0f94db93..fe72ae80 100644 --- a/gvsds/money.py +++ b/gvsds/money.py @@ -1,92 +1,8 @@ -"""模块:金额安全换算工具 +"""兼容转发层:金额安全换算实现已下沉 gvsdsdk.money,此处仅 re-export 既有导入路径。 -提供统一的金额转换与四舍五入规范,禁止 float 参与元/分转换。 -所有金额运算一律基于 Decimal,避免浮点精度丢失。 - -常用 API: - from gvsds.money import ToDecimal, QuantizeYuan, YuanToFen, FenToYuan, FormatYuan - - amount = ToDecimal('25.005') # Decimal('25.005') - yuan = QuantizeYuan(amount) # Decimal('25.01') 银行家舍入 - fen = YuanToFen(amount) # 2501 整数分 - yuan2 = FenToYuan(fen) # Decimal('25.01') - text = FormatYuan(amount) # '25.01' +新代码请直接 `from gvsdsdk.money import ...`。 """ -from decimal import Decimal, ROUND_HALF_EVEN, InvalidOperation -from functools import wraps - -# 元精度(2 位小数),分精度(0 位小数) -_YUAN_QUANT = Decimal('0.01') -_FEN_QUANT = Decimal('1') - - -def ToDecimal(value, default=Decimal('0')): - """安全转 Decimal,非法值返回 default。 - - 禁止 float 直接转换(会引入精度误差),float 先转 str 再转 Decimal。 - """ - if value is None or value == '': - return default - if isinstance(value, Decimal): - return value - if isinstance(value, float): - return Decimal(str(value)) - try: - return Decimal(value) - except (InvalidOperation, TypeError, ValueError): - return default - - -def QuantizeYuan(value, rounding=ROUND_HALF_EVEN): - """按元精度(2 位小数)四舍五入,默认银行家舍入。""" - return ToDecimal(value).quantize(_YUAN_QUANT, rounding=rounding) - - -def YuanToFen(value): - """元 → 分(整数)。先量化到 2 位小数再乘 100,避免精度漂移。""" - return int((QuantizeYuan(value) * 100).to_integral_value(rounding=ROUND_HALF_EVEN)) - - -def FenToYuan(fen): - """分 → 元(Decimal,2 位小数)。""" - return QuantizeYuan(ToDecimal(fen) / 100) - - -def FormatYuan(value, with_comma=False): - """格式化元金额为字符串。 - - with_comma=True 时返回千分位格式(如 '1,234.56')。 - """ - q = QuantizeYuan(value) - if with_comma: - return f'{q:,.2f}' - return str(q) - - -def SafeDecimalOperation(default=Decimal('0')): - """装饰器:函数内任何 Decimal 异常均返回 default。 - - 适用于聚合统计等不能因单条脏数据中断的场景。 - """ - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except (InvalidOperation, TypeError, ValueError, ArithmeticError): - return default - return wrapper - return decorator - - -def AddYuan(*values): - """多个金额相加并量化到元。空入参返回 Decimal('0.00')。""" - total = Decimal('0') - for v in values: - total += ToDecimal(v) - return QuantizeYuan(total) - - -def MultiplyYuan(unit_price, quantity): - """单价 × 数量 → 量化到元的金额。quantity 可为 Decimal/int/str。""" - return QuantizeYuan(ToDecimal(unit_price) * ToDecimal(quantity)) +from gvsdsdk.money import ( # noqa: F401 + ToDecimal, QuantizeYuan, YuanToFen, FenToYuan, FormatYuan, + SafeDecimalOperation, AddYuan, MultiplyYuan, +) diff --git a/gvsds/redis_lock.py b/gvsds/redis_lock.py index a403336b..c8cd84da 100644 --- a/gvsds/redis_lock.py +++ b/gvsds/redis_lock.py @@ -1,154 +1,5 @@ -"""模块:Redis 分布式锁 +"""兼容转发层:Redis 分布式锁实现已下沉 gvsdsdk.redis_lock。 -基于 Django cache 框架实现,底层使用 Redis SET NX EX 原子操作。 -提供上下文管理器与装饰器两种用法,跨进程互斥。 - -依赖:settings.CACHES 配置 redis 后端(见 settings.py)。 - -常用 API: - from gvsds.redis_lock import RedisLock, redis_locked - - # 上下文管理器 - with RedisLock('order:pay:123', timeout=30) as acquired: - if not acquired: - raise RuntimeError('订单正在处理中,请勿重复提交') - DoPay(order) - - # 装饰器 - @redis_locked('resource_key', timeout=60, wait=True) - def CriticalTask(pk): - ... - -若未配置 Redis 缓存,自动降级为 no-op(acquired 永远 True),便于本地开发。 +新代码请直接 `from gvsdsdk.redis_lock import RedisLock, redis_locked`。 """ -import logging -import time -import uuid -from functools import wraps - -from django.core.cache import cache - -logger = logging.getLogger(__name__) - -_LOCK_PREFIX = 'gvsds:lock:' - - -def _is_redis_backend(): - """探测当前 cache 是否为 redis 后端。""" - try: - backend = cache.__class__.__module__ + '.' + cache.__class__.__name__ - return 'redis' in backend.lower() - except Exception: - return False - - -_REDIS_OK = None - - -def _HasRedis(): - """惰性探测 Redis 是否可用(结果缓存)。""" - global _REDIS_OK - if _REDIS_OK is None: - _REDIS_OK = _is_redis_backend() - return _REDIS_OK - - -class RedisLock: - """分布式锁(上下文管理器)。 - - 参数: - key 锁名(自动加前缀避免冲突) - timeout 锁超时秒数(防死锁,到期自动释放) - wait 未获取时是否阻塞等待 - retry 阻塞时重试间隔秒 - retry_count 阻塞时最大重试次数(0=不重试) - """ - - def __init__(self, key, timeout=30, wait=False, retry=0.1, retry_count=100): - self.full_key = _LOCK_PREFIX + key - self.timeout = timeout - self.wait = wait - self.retry = retry - self.retry_count = retry_count - self.token = uuid.uuid4().hex - self._acquired = False - - def acquire(self): - """尝试获取锁,返回是否成功。""" - if not _HasRedis(): - # 无 Redis 时降级为 no-op - self._acquired = True - return True - # add 是原子的:仅当 key 不存在时设置 - got = cache.add(self.full_key, self.token, timeout=self.timeout) - if got: - self._acquired = True - return True - if not self.wait: - return False - for _ in range(self.retry_count): - time.sleep(self.retry) - got = cache.add(self.full_key, self.token, timeout=self.timeout) - if got: - self._acquired = True - return True - return False - - def release(self): - """释放锁。仅当 token 匹配才删除,防止误释放他人持有的锁。""" - if not self._acquired or not _HasRedis(): - return - try: - current = cache.get(self.full_key) - if current == self.token: - cache.delete(self.full_key) - except Exception: - logger.warning('[RedisLock] 释放锁失败: %s', self.full_key, exc_info=True) - finally: - self._acquired = False - - def __enter__(self): - return self.acquire() - - def __exit__(self, exc_type, exc_val, exc_tb): - self.release() - return False - - @property - def acquired(self): - return self._acquired - - -def redis_locked(key, timeout=30, wait=False, retry=0.1, retry_count=100, key_arg=None): - """装饰器形式分布式锁。 - - 参数: - key 静态锁名 - key_arg 动态锁名取自被装饰函数的参数索引或参数名 - 如 key_arg=0 取第一个位置参数,key_arg='pk' 取名为 pk 的关键字参数 - timeout 锁超时秒 - wait 未获取时是否阻塞等待 - - 用法: - @redis_locked('order:pay', timeout=30, key_arg='order_uuid') - def PayOrder(order_uuid): - ... - """ - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - if key_arg is not None: - if isinstance(key_arg, int) and key_arg < len(args): - lock_key = f'{key}:{args[key_arg]}' - elif key_arg in kwargs: - lock_key = f'{key}:{kwargs[key_arg]}' - else: - lock_key = key - else: - lock_key = key - with RedisLock(lock_key, timeout=timeout, wait=wait, retry=retry, retry_count=retry_count) as got: - if not got: - raise RuntimeError(f'资源 {lock_key} 正被占用,请稍后重试') - return func(*args, **kwargs) - return wrapper - return decorator +from gvsdsdk.redis_lock import RedisLock, redis_locked # noqa: F401 diff --git a/gvsds/storage.py b/gvsds/storage.py index bbd2766a..74979ef4 100644 --- a/gvsds/storage.py +++ b/gvsds/storage.py @@ -1,320 +1,13 @@ -"""统一 OSS 云存储抽象层。 +"""兼容转发层:统一存储抽象实现已下沉 gvsdsdk.storage。 -支持三种后端,通过 app.ini [storage] 段 BACKEND 切换: - - local: 本地磁盘存储(默认,零依赖) - - cos: 腾讯云 COS(需 cos-python-sdk-v5) - - oss: 阿里云 OSS(需 oss2) - -统一 API(帕斯卡命名): - from gvsds.storage import Storage - url = Storage.Upload('assets/2024/abc.jpg', file_bytes, ContentType='image/jpeg') - url = Storage.UploadFile('assets/2024/abc.jpg', file_object, ContentType='image/jpeg') - Storage.Delete('assets/2024/abc.jpg') - url = Storage.SignedUrl('assets/2024/abc.jpg', Expires=3600) - exists = Storage.Exists('assets/2024/abc.jpg') +本模块仅做两件事: + 1. 注入 app.ini 配置提供者(SDK 不反向依赖业务层配置); + 2. re-export 既有符号,保持 `from gvsds.storage import Storage` 等路径不变。 """ +from gvsds.app_config import get as _app_get, getlist as _app_getlist +from gvsdsdk.config import SetConfigProvider +from gvsdsdk.storage import ( # noqa: F401 + Storage, StorageBackend, LocalBackend, CosBackend, OssBackend, _NormalizeKey, +) -import os -import logging -from pathlib import Path - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - -from gvsds.app_config import get as _config_get - -__all__ = ['Storage'] - -Logger = logging.getLogger('gvsds.storage') - - -def _NormalizeKey(Key): - """规范化 key:去除前导斜杠,防止 .. 路径穿越。""" - if not Key or not isinstance(Key, str): - raise ValueError('key 必须是非空字符串') - Key = Key.lstrip('/') - Norm = os.path.normpath(Key).replace('\\', '/') - Parts = Norm.split('/') - if '..' in Parts or Norm.startswith('/') or Norm == '.': - raise ValueError(f'非法 key(路径穿越): {Key}') - return Norm - - -class StorageBackend: - """存储后端基类。""" - - def Upload(self, Key, Data, ContentType=None): - raise NotImplementedError - - def Delete(self, Key): - raise NotImplementedError - - def SignedUrl(self, Key, Expires=3600): - raise NotImplementedError - - def Exists(self, Key): - raise NotImplementedError - - -class LocalBackend(StorageBackend): - """本地磁盘存储后端。""" - - def __init__(self): - BaseDir = _config_get('storage', 'LOCAL_BASE_DIR') - self.BaseDir = Path(BaseDir) if BaseDir else Path(settings.MEDIA_ROOT) - BaseUrl = _config_get('storage', 'LOCAL_BASE_URL') - self.BaseUrl = BaseUrl if BaseUrl else settings.MEDIA_URL - self.BaseDir.mkdir(parents=True, exist_ok=True) - - def Upload(self, Key, Data, ContentType=None): - try: - Key = _NormalizeKey(Key) - if hasattr(Data, 'read'): - Data = Data.read() - if not isinstance(Data, (bytes, bytearray, memoryview)): - raise ValueError('Data 必须是 bytes 或 file-like 对象') - FilePath = self.BaseDir / Key - FilePath.parent.mkdir(parents=True, exist_ok=True) - FilePath.write_bytes(bytes(Data)) - return self._BuildUrl(Key) - except Exception as e: - Logger.exception('LocalBackend Upload 失败: key=%s', Key) - raise RuntimeError(f'本地存储上传失败: {e}') from e - - def Delete(self, Key): - try: - Key = _NormalizeKey(Key) - FilePath = self.BaseDir / Key - # 解析后确认仍在 BaseDir 内,双重防穿越 - Resolved = FilePath.resolve() - if not str(Resolved).startswith(str(self.BaseDir.resolve())): - raise ValueError(f'非法 key(越界): {Key}') - if FilePath.exists() and FilePath.is_file(): - FilePath.unlink() - except Exception as e: - Logger.exception('LocalBackend Delete 失败: key=%s', Key) - raise RuntimeError(f'本地存储删除失败: {e}') from e - - def SignedUrl(self, Key, Expires=3600): - # local 后端直接返回公开 URL - try: - Key = _NormalizeKey(Key) - return self._BuildUrl(Key) - except Exception as e: - Logger.exception('LocalBackend SignedUrl 失败: key=%s', Key) - raise RuntimeError(f'本地存储生成 URL 失败: {e}') from e - - def Exists(self, Key): - try: - Key = _NormalizeKey(Key) - FilePath = self.BaseDir / Key - Resolved = FilePath.resolve() - if not str(Resolved).startswith(str(self.BaseDir.resolve())): - return False - return FilePath.exists() and FilePath.is_file() - except Exception as e: - Logger.exception('LocalBackend Exists 失败: key=%s', Key) - raise RuntimeError(f'本地存储检查失败: {e}') from e - - def _BuildUrl(self, Key): - BaseUrl = self.BaseUrl - if not BaseUrl.endswith('/'): - BaseUrl += '/' - return BaseUrl + Key - - -class CosBackend(StorageBackend): - """腾讯云 COS 存储后端。""" - - def __init__(self): - try: - from qcloud_cos import CosConfig, CosS3Client - except ImportError: - raise ImproperlyConfigured( - '腾讯云 COS 后端需要安装 cos-python-sdk-v5: pip install cos-python-sdk-v5' - ) - self.SecretId = _config_get('storage', 'COS_SECRET_ID') - self.SecretKey = _config_get('storage', 'COS_SECRET_KEY') - self.Region = _config_get('storage', 'COS_REGION') - self.Bucket = _config_get('storage', 'COS_BUCKET') - self.CdnDomain = _config_get('storage', 'COS_CDN_DOMAIN') - if not all([self.SecretId, self.SecretKey, self.Region, self.Bucket]): - raise ImproperlyConfigured( - 'COS 配置不完整,需 COS_SECRET_ID/COS_SECRET_KEY/COS_REGION/COS_BUCKET' - ) - Config = CosConfig(Region=self.Region, SecretId=self.SecretId, SecretKey=self.SecretKey) - self.Client = CosS3Client(Config) - - def Upload(self, Key, Data, ContentType=None): - try: - Key = _NormalizeKey(Key) - if hasattr(Data, 'read'): - Data = Data.read() - if not isinstance(Data, (bytes, bytearray, memoryview)): - raise ValueError('Data 必须是 bytes 或 file-like 对象') - self.Client.put_object( - Bucket=self.Bucket, - Body=bytes(Data), - Key=Key, - EnableMD5=True, - ) - return self._BuildUrl(Key) - except Exception as e: - Logger.exception('CosBackend Upload 失败: key=%s', Key) - raise RuntimeError(f'COS 上传失败: {e}') from e - - def Delete(self, Key): - try: - Key = _NormalizeKey(Key) - self.Client.delete_object(Bucket=self.Bucket, Key=Key) - except Exception as e: - Logger.exception('CosBackend Delete 失败: key=%s', Key) - raise RuntimeError(f'COS 删除失败: {e}') from e - - def SignedUrl(self, Key, Expires=3600): - try: - Key = _NormalizeKey(Key) - return self.Client.get_presigned_url( - Method='GET', - Bucket=self.Bucket, - Key=Key, - Expired=Expires, - ) - except Exception as e: - Logger.exception('CosBackend SignedUrl 失败: key=%s', Key) - raise RuntimeError(f'COS 生成签名 URL 失败: {e}') from e - - def Exists(self, Key): - try: - Key = _NormalizeKey(Key) - return self.Client.object_exists(Bucket=self.Bucket, Key=Key) - except Exception as e: - Logger.exception('CosBackend Exists 失败: key=%s', Key) - raise RuntimeError(f'COS 检查存在失败: {e}') from e - - def _BuildUrl(self, Key): - if self.CdnDomain: - return f'{self.CdnDomain.rstrip("/")}/{Key}' - return f'https://{self.Bucket}.cos.{self.Region}.myqcloud.com/{Key}' - - -class OssBackend(StorageBackend): - """阿里云 OSS 存储后端。""" - - def __init__(self): - try: - import oss2 - except ImportError: - raise ImproperlyConfigured( - '阿里云 OSS 后端需要安装 oss2: pip install oss2' - ) - self.AccessKey = _config_get('storage', 'OSS_ACCESS_KEY') - self.AccessSecret = _config_get('storage', 'OSS_ACCESS_SECRET') - self.Endpoint = _config_get('storage', 'OSS_ENDPOINT') - self.Bucket = _config_get('storage', 'OSS_BUCKET') - self.CdnDomain = _config_get('storage', 'OSS_CDN_DOMAIN') - if not all([self.AccessKey, self.AccessSecret, self.Endpoint, self.Bucket]): - raise ImproperlyConfigured( - 'OSS 配置不完整,需 OSS_ACCESS_KEY/OSS_ACCESS_SECRET/OSS_ENDPOINT/OSS_BUCKET' - ) - Auth = oss2.Auth(self.AccessKey, self.AccessSecret) - self.BucketObj = oss2.Bucket(Auth, self.Endpoint, self.Bucket) - - def Upload(self, Key, Data, ContentType=None): - try: - Key = _NormalizeKey(Key) - if hasattr(Data, 'read'): - Data = Data.read() - if not isinstance(Data, (bytes, bytearray, memoryview)): - raise ValueError('Data 必须是 bytes 或 file-like 对象') - Headers = {} - if ContentType: - Headers['Content-Type'] = ContentType - self.BucketObj.put_object(Key, bytes(Data), headers=Headers) - return self._BuildUrl(Key) - except Exception as e: - Logger.exception('OssBackend Upload 失败: key=%s', Key) - raise RuntimeError(f'OSS 上传失败: {e}') from e - - def Delete(self, Key): - try: - Key = _NormalizeKey(Key) - self.BucketObj.delete_object(Key) - except Exception as e: - Logger.exception('OssBackend Delete 失败: key=%s', Key) - raise RuntimeError(f'OSS 删除失败: {e}') from e - - def SignedUrl(self, Key, Expires=3600): - try: - Key = _NormalizeKey(Key) - return self.BucketObj.sign_url('GET', Key, Expires) - except Exception as e: - Logger.exception('OssBackend SignedUrl 失败: key=%s', Key) - raise RuntimeError(f'OSS 生成签名 URL 失败: {e}') from e - - def Exists(self, Key): - try: - Key = _NormalizeKey(Key) - return self.BucketObj.object_exists(Key) - except Exception as e: - Logger.exception('OssBackend Exists 失败: key=%s', Key) - raise RuntimeError(f'OSS 检查存在失败: {e}') from e - - def _BuildUrl(self, Key): - if self.CdnDomain: - return f'{self.CdnDomain.rstrip("/")}/{Key}' - Endpoint = self.Endpoint - for Scheme in ('https://', 'http://'): - if Endpoint.startswith(Scheme): - Endpoint = Endpoint[len(Scheme):] - break - return f'https://{self.Bucket}.{Endpoint}/{Key}' - - -class Storage: - """统一存储抽象层(单例门面)。 - - 首次访问时根据 app.ini [storage] BACKEND 初始化对应后端并缓存。 - """ - - _Backend = None - - @classmethod - def _GetBackend(cls): - if cls._Backend is None: - BackendName = (_config_get('storage', 'BACKEND') or 'local').lower() - if BackendName == 'local': - cls._Backend = LocalBackend() - elif BackendName == 'cos': - cls._Backend = CosBackend() - elif BackendName == 'oss': - cls._Backend = OssBackend() - else: - raise ImproperlyConfigured(f'不支持的存储后端: {BackendName}') - Logger.info('存储后端已初始化: %s', BackendName) - return cls._Backend - - @classmethod - def Upload(cls, Key, Data, ContentType=None): - """上传 bytes 或 file-like 对象,返回可访问 URL。""" - return cls._GetBackend().Upload(Key, Data, ContentType) - - @classmethod - def UploadFile(cls, Key, FileObj, ContentType=None): - """上传文件对象(有 read 方法),返回可访问 URL。""" - return cls._GetBackend().Upload(Key, FileObj, ContentType) - - @classmethod - def Delete(cls, Key): - """删除指定 key 的对象。""" - return cls._GetBackend().Delete(Key) - - @classmethod - def SignedUrl(cls, Key, Expires=3600): - """生成签名 URL(local 后端直接返回公开 URL)。""" - return cls._GetBackend().SignedUrl(Key, Expires) - - @classmethod - def Exists(cls, Key): - """判断指定 key 是否存在。""" - return cls._GetBackend().Exists(Key) +SetConfigProvider(_app_get, _app_getlist) diff --git a/gvsds/tests/test_attack_detect.py b/gvsds/tests/test_attack_detect.py index 10562a6e..111b5f71 100644 --- a/gvsds/tests/test_attack_detect.py +++ b/gvsds/tests/test_attack_detect.py @@ -60,6 +60,8 @@ class AttackDetectMiddlewareTestCase(SimpleTestCase): mwf.status_codes = set(status_codes or [401, 403, 429]) mwf.exempt_paths = tuple(exempt_paths or ['/API/Auth', '/health']) mwf.max_body_len = max_body_len + #: 绕过 __init__ 时需补齐限频去重状态,否则 _log_attack 内 AttributeError 被吞 + mwf._recent_attacks = {} return mwf def testDisabled_PassesThrough(self): @@ -133,6 +135,8 @@ class LogAttackTestCase(SimpleTestCase): def _BuildMiddleware(self, max_body_len=1024): mwf = mw.AttackDetectMiddleware.__new__(mw.AttackDetectMiddleware) mwf.max_body_len = max_body_len + #: 绕过 __init__ 时需补齐限频去重状态,否则 _log_attack 内 AttributeError 被吞 + mwf._recent_attacks = {} return mwf def testLogAttack_WritesToAbnormalUserLog(self): diff --git a/gvsds/tests/test_celery_utils.py b/gvsds/tests/test_celery_utils.py index 3ac0271a..6ba497dd 100644 --- a/gvsds/tests/test_celery_utils.py +++ b/gvsds/tests/test_celery_utils.py @@ -8,7 +8,7 @@ from unittest import mock from django.test import SimpleTestCase -from gvsds import celery_utils +from gvsdsdk import celery_utils class RollbackOnFailureTestCase(SimpleTestCase): diff --git a/gvsds/tests/test_money.py b/gvsds/tests/test_money.py index 23dc1596..5aae44ba 100644 --- a/gvsds/tests/test_money.py +++ b/gvsds/tests/test_money.py @@ -7,7 +7,7 @@ FormatYuan / AddYuan / MultiplyYuan / SafeDecimalOperation, from decimal import Decimal, ROUND_HALF_UP from django.test import SimpleTestCase -from gvsds.money import ( +from gvsdsdk.money import ( ToDecimal, QuantizeYuan, YuanToFen, FenToYuan, FormatYuan, AddYuan, MultiplyYuan, SafeDecimalOperation, ) diff --git a/gvsds/tests/test_redis_lock.py b/gvsds/tests/test_redis_lock.py index 5c9f8302..ebdc8c53 100644 --- a/gvsds/tests/test_redis_lock.py +++ b/gvsds/tests/test_redis_lock.py @@ -9,7 +9,7 @@ from unittest import mock from django.test import SimpleTestCase -from gvsds import redis_lock +from gvsdsdk import redis_lock class HasRedisDetectionTestCase(SimpleTestCase): @@ -129,7 +129,7 @@ class RedisLockWithRedisTestCase(SimpleTestCase): def testAcquire_CacheAddSuccess_ReturnsTrue(self): lock = redis_lock.RedisLock('win-key', timeout=5) - with mock.patch('gvsds.redis_lock.cache') as mock_cache: + with mock.patch('gvsdsdk.redis_lock.cache') as mock_cache: mock_cache.add.return_value = True self.assertTrue(lock.acquire()) mock_cache.add.assert_called_once() @@ -139,14 +139,14 @@ class RedisLockWithRedisTestCase(SimpleTestCase): def testAcquire_CacheAddFail_NoWait_ReturnsFalse(self): lock = redis_lock.RedisLock('lose-key', wait=False) - with mock.patch('gvsds.redis_lock.cache') as mock_cache: + with mock.patch('gvsdsdk.redis_lock.cache') as mock_cache: mock_cache.add.return_value = False self.assertFalse(lock.acquire()) def testRelease_TokenMatch_Deletes(self): lock = redis_lock.RedisLock('rel-key') lock._acquired = True - with mock.patch('gvsds.redis_lock.cache') as mock_cache: + with mock.patch('gvsdsdk.redis_lock.cache') as mock_cache: mock_cache.get.return_value = lock.token lock.release() mock_cache.delete.assert_called_once() @@ -154,13 +154,13 @@ class RedisLockWithRedisTestCase(SimpleTestCase): def testRelease_TokenMismatch_DoesNotDelete(self): lock = redis_lock.RedisLock('rel-key') lock._acquired = True - with mock.patch('gvsds.redis_lock.cache') as mock_cache: + with mock.patch('gvsdsdk.redis_lock.cache') as mock_cache: mock_cache.get.return_value = 'other-token' lock.release() mock_cache.delete.assert_not_called() def testRelease_NotAcquired_NoOp(self): lock = redis_lock.RedisLock('rel-key') - with mock.patch('gvsds.redis_lock.cache') as mock_cache: + with mock.patch('gvsdsdk.redis_lock.cache') as mock_cache: lock.release() mock_cache.get.assert_not_called() diff --git a/gvsds/tests/test_storage.py b/gvsds/tests/test_storage.py index 32a5d2bd..efe04bb9 100644 --- a/gvsds/tests/test_storage.py +++ b/gvsds/tests/test_storage.py @@ -13,7 +13,8 @@ from unittest import mock from django.test import SimpleTestCase, override_settings -from gvsds import storage +from gvsds import storage as _ShimStorage # noqa: F401 兼容转发层:为 SDK 注入 app.ini 配置提供者 +from gvsdsdk import storage class NormalizeKeyTestCase(SimpleTestCase): @@ -170,7 +171,7 @@ class StorageFacadeTestCase(SimpleTestCase): if key == 'BACKEND': return backend return None - return mock.patch('gvsds.storage._config_get', side_effect=_SideEffect) + return mock.patch('gvsdsdk.storage._config_get', side_effect=_SideEffect) def testGetBackend_Local_CachesInstance(self): with self._MockConfigGet():