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:
1 parent
052f48d526
commit
31b0ab885f
32 files changed
+388
-1760
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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 [], ''
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 级统一交易记录
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user