refactor(orm): 手写 ORM 下沉 gvsdsdk Manager,通用工具改为 SDK 转发

P1 通用查询:viewsets / app / menu / mail / gitea / mc / stats / sync_apps 等视图与命令的裸查询改委托 SDK Manager(Role/Company/Department/Position/Menu/Tenant/App)。

P2 App 体系:app_manager.py 与 app_manifests.py 实现零裸 ORM,职务多值/虚拟职务/公司下拉全部走 SDK。

P3 工具下沉:money/json_encoder/celery_utils/exception_handler/redis_lock 改为纯转发展开;ip_filter/storage 转发并注入 app.ini 配置源;common 下 DateTime/String/Naming/UUID 转发 SDK。

删除死代码 commission/strategies/engine.py(SDK 已有同物且零引用)与 payment/models.py 的 FromTenantAndPlatform;单测 patch 目标同步为 gvsdsdk.*,test_attack_detect fixture 补齐限频去重状态。
This commit is contained in:
TermiNexus committed 2026-09-13 21:58:06 +08:00
1 parent 052f48d526
commit 31b0ab885f
32 files changed
+388 -1760

No files matched your search

Whitespace-only changes.
-156
View File
@@ -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)
+14 -11
View File
@@ -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]:
+2 -2
View File
@@ -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
+123 -289
View File
@@ -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 [], ''
+43 -65
View File
@@ -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:
+28 -28
View File
@@ -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:
@@ -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)
+56 -15
View File
@@ -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:
+4 -5
View File
@@ -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):
+4 -5
View File
@@ -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):
+6 -17
View File
@@ -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,
+18 -29
View File
@@ -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:
+4 -12
View File
@@ -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,
-7
View File
@@ -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 级统一交易记录
+4 -6
View File
@@ -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 []
+5 -117
View File
@@ -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,
)
+4 -24
View File
@@ -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,
)
+8 -123
View File
@@ -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,
)
+4 -9
View File
@@ -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,
)
+8 -84
View File
@@ -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,
)
+4 -53
View File
@@ -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
+9 -113
View File
@@ -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)
+4 -18
View File
@@ -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
+6 -90
View File
@@ -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,
)
+3 -152
View File
@@ -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
+10 -317
View File
@@ -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)
+4
View File
@@ -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):
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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,
)
+6 -6
View File
@@ -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()
+3 -2
View File
@@ -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():