diff --git a/TempLate/GERP/index.html b/TempLate/GERP/index.html
index eda982c8..a26dec18 100644
--- a/TempLate/GERP/index.html
+++ b/TempLate/GERP/index.html
@@ -5,7 +5,7 @@
GERP 管理系统
-
+
@@ -14,7 +14,7 @@
-
+
diff --git a/gvsds/apps/gerp/app_access.py b/gvsds/apps/gerp/app_access.py
index 89cb4440..051fb191 100644
--- a/gvsds/apps/gerp/app_access.py
+++ b/gvsds/apps/gerp/app_access.py
@@ -1,8 +1,10 @@
"""模块:应用级访问控制(AppAccess)
将功能/应用体系的 CheckAccess 硬校验挂到各应用 API 入口:
-- 管理员(admin:super / admin:tenant):豁免——与菜单过滤逻辑一致,管理员全览
-- 普通用户:按 AppManager.CheckAccess 校验(企业功能按租户安装状态、个人应用按个人启用状态)
+- 所有用户(含管理员):严格按安装状态校验——
+ 企业功能 = 当前租户已装 或 当前公司已装;个人应用 = 个人已启用
+- 管理员不豁免业务 API:未安装应用对管理员同样不可用(避免白嫖未安装功能)
+- 管理员仅豁免应用中心管理(安装/停用/审批视图走 IsAdminUser,不挂本权限)
- 未安装/未启用:返回 403(DRF permission 拒绝)
用法(视图 permission_classes):
@@ -28,10 +30,6 @@ def AppAccessPermission(AppCode):
user = getattr(request, 'user', None)
if not (user and getattr(user, 'IsAuthenticated', False)):
return False
- # 管理员豁免(与菜单过滤一致:admin:super/admin:tenant 全览)
- from gvsdsdk.auth.admin_service import IsAdmin
- if IsAdmin(request=request, user=user):
- return True
# 解析当前租户/用户上下文(CurrentRoleMiddleware 已设置 request.current_tenant_uuid)
tenant_uuid = getattr(request, 'current_tenant_uuid', None)
user_uuid = getattr(user, 'UserUUID', None)
diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py
index 59770ed6..f1b8b2d3 100644
--- a/gvsds/apps/gerp/app_manager.py
+++ b/gvsds/apps/gerp/app_manager.py
@@ -66,6 +66,10 @@ def _AppFlat(app, tenant_installed=None, user_record=None, company_installed=Non
'AuthRequired': app.AuthRequired,
'MenuRootUUID': UUIDObj(app.MenuRootUUID).str if app.MenuRootUUID else '',
'AppSort': app.AppSort or 0,
+ # 前端入口(真应用注册协议:安装后"打开应用"跳转目标)
+ 'FrontendRoute': app.FrontendRoute or '',
+ 'FrontendType': app.FrontendType or 'vue',
+ 'LegacySrc': app.LegacySrc or '',
# 状态附加(市场页/我的应用展示用)
'TenantInstalled': 1 if tenant_installed and tenant_installed.InstallStatus == 1 else 0,
'TenantAuth': tenant_installed.AuthStatus if tenant_installed else 0,
@@ -213,8 +217,39 @@ class AppManager:
if app.AuthRequired == 1 and ta.AuthStatus != AUTH_OK:
ta.AuthStatus = AUTH_OK
ta.save(update_fields=['InstallStatus', 'AuthStatus'])
+ # 真应用协议:安装后注入菜单(MenuStatus=1)
+ try:
+ from .app_manifests import InjectAppMenus
+ InjectAppMenus(app.AppCode)
+ except Exception:
+ pass
return True, '', _AppFlat(app, tenant_installed=ta)
+ @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
+
+ @staticmethod
+ def _MaybeRemoveAppMenus(AppUUID):
+ """真应用协议:全局已无任何启用安装记录时移除菜单(MenuStatus=0)"""
+ app = AppManager.GetApp(AppUUID)
+ if app is None:
+ return
+ try:
+ from .app_manifests import RemoveAppMenus
+ if not AppManager._AnyEnabledInstall(AppUUID):
+ RemoveAppMenus(app.AppCode)
+ except Exception:
+ pass
+
@staticmethod
def UninstallTenantApp(TenantUUID, AppUUID):
"""租户停用企业功能"""
@@ -224,6 +259,7 @@ class AppManager:
return False, '该功能未安装'
ta.InstallStatus = 0
ta.save(update_fields=['InstallStatus'])
+ AppManager._MaybeRemoveAppMenus(AppUUID)
return True, ''
# ==================== 公司维度安装/停用 ====================
@@ -265,6 +301,12 @@ class AppManager:
if install_by:
ca.InstallBy = install_by
ca.save(update_fields=['InstallStatus', 'InstallBy'])
+ # 真应用协议:安装后注入菜单(MenuStatus=1)
+ try:
+ from .app_manifests import InjectAppMenus
+ InjectAppMenus(app.AppCode)
+ except Exception:
+ pass
return True, '', len(company_list)
@staticmethod
@@ -286,6 +328,7 @@ class AppManager:
updated += 1
if updated == 0:
return False, '所选公司均未安装该功能'
+ AppManager._MaybeRemoveAppMenus(AppUUID)
return True, ''
@staticmethod
@@ -307,6 +350,137 @@ class AppManager:
}
return result
+ @staticmethod
+ def ListAppCompanyInstalls(AppUUID, CompanyUUIDs):
+ """查询指定应用在若干公司的安装状态(管理弹窗用)
+
+ Args:
+ AppUUID: 应用 UUID(str/bytes/UUIDObj)
+ CompanyUUIDs: 公司 UUID 列表(bytes 列表)
+
+ Returns:
+ dict[bytes, int]:公司 UUID bytes → InstallStatus(1已装/0未装)
+ """
+ app_bytes = _ToBytes(AppUUID)
+ if app_bytes is None:
+ return {}
+ company_list = [_ToBytes(c) for c in (CompanyUUIDs or []) if _ToBytes(c)]
+ 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):
+ result[_ToBytes(ca.CompanyUUID)] = ca.InstallStatus
+ return result
+
+ # ==================== 应用配置(AppConfig 键值表) ====================
+
+ @staticmethod
+ def GetAppConfigTemplate(AppCode):
+ """返回应用配置模板(Manifest 声明的配置键)
+
+ Returns:
+ list[dict]:{Key, Label, Default, Secret, Placeholder}
+ """
+ from .app_manifests import GetAppManifest
+ manifest = GetAppManifest(AppCode)
+ if manifest is None:
+ return []
+ return [
+ {
+ 'Key': spec.Key,
+ 'Label': spec.Label or spec.Key,
+ 'Default': spec.Default or '',
+ 'Secret': 1 if spec.Secret else 0,
+ 'Placeholder': spec.Placeholder or '',
+ }
+ for spec in manifest.ConfigKeys
+ ]
+
+ @staticmethod
+ def GetAppConfig(TenantUUID, AppUUID):
+ """读取指定租户下应用配置(模板 + 已保存值)
+
+ Returns:
+ list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated}
+ """
+ from .models import AppConfig
+ app = AppManager.GetApp(AppUUID)
+ if app is None:
+ return []
+ template = AppManager.GetAppConfigTemplate(app.AppCode)
+ if not template:
+ return []
+ tenant_bytes = _ToBytes(TenantUUID)
+ app_bytes = _ToBytes(AppUUID)
+ saved = {}
+ if tenant_bytes:
+ for cfg in AppConfig.objects.filter(TenantUUID=tenant_bytes, AppUUID=app_bytes):
+ saved[cfg.ConfigKey] = cfg
+ result = []
+ for t in template:
+ key = t['Key']
+ cfg = saved.get(key)
+ result.append({
+ 'Key': key,
+ 'Label': t['Label'],
+ 'Default': t['Default'],
+ 'Secret': t['Secret'],
+ 'Placeholder': t['Placeholder'],
+ 'Value': cfg.ConfigValue if cfg else t['Default'],
+ 'Updated': cfg is not None,
+ })
+ return result
+
+ @staticmethod
+ def SaveAppConfig(TenantUUID, AppUUID, Updates, OperatorUUID=None):
+ """保存租户应用配置(按模板键 upsert)
+
+ Args:
+ TenantUUID: 租户 UUID
+ AppUUID: 应用 UUID
+ Updates: dict {Key: Value}
+ OperatorUUID: 更新人 UUID(可选)
+
+ Returns:
+ (ok, err, updated_keys)
+ """
+ import datetime as _dt
+ from .models import AppConfig
+ app = AppManager.GetApp(AppUUID)
+ if app is None:
+ return False, '应用不存在', []
+ template = AppManager.GetAppConfigTemplate(app.AppCode)
+ if not template:
+ return False, '该应用未声明配置项', []
+ allowed = {t['Key'] for t in template}
+ tenant_bytes = _ToBytes(TenantUUID)
+ app_bytes = _ToBytes(AppUUID)
+ operator_bytes = _ToBytes(OperatorUUID)
+ updated = []
+ for key, value in (Updates or {}).items():
+ if key not in allowed:
+ continue
+ if not isinstance(value, str):
+ value = str(value)
+ cfg, created = AppConfig.objects.get_or_create(
+ TenantUUID=tenant_bytes,
+ AppUUID=app_bytes,
+ ConfigKey=key,
+ defaults={
+ 'AppConfigUUID': uuid.uuid4().bytes,
+ 'ConfigValue': value,
+ 'UpdateBy': operator_bytes,
+ 'UpdateTime': _dt.datetime.utcnow(),
+ },
+ )
+ 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
+
# ==================== 个人启用/申请/审批 ====================
@staticmethod
@@ -335,11 +509,22 @@ class AppManager:
return False, '该应用需企业授权,请先申请(待租户管理员审批)', None
ua.UseStatus = 1
ua.save(update_fields=['UseStatus'])
+ AppManager._InjectMenusSafe(app.AppCode)
return True, '', _AppFlat(app, user_record=ua)
ua.UseStatus = 1
ua.save(update_fields=['UseStatus'])
+ AppManager._InjectMenusSafe(app.AppCode)
return True, '', _AppFlat(app, user_record=ua)
+ @staticmethod
+ def _InjectMenusSafe(AppCode):
+ """真应用协议:安装/启用后注入菜单(异常静默)"""
+ try:
+ from .app_manifests import InjectAppMenus
+ InjectAppMenus(AppCode)
+ except Exception:
+ pass
+
@staticmethod
def DisableUserApp(UserUUID, AppUUID):
"""个人停用应用"""
@@ -349,6 +534,7 @@ class AppManager:
return False, '未启用该应用'
ua.UseStatus = 0
ua.save(update_fields=['UseStatus'])
+ AppManager._MaybeRemoveAppMenus(AppUUID)
return True, ''
@staticmethod
@@ -484,13 +670,13 @@ class AppManager:
from gvsdsdk.auth.admin_service import IsSuperAdmin, IsTenantAdmin, IsCompanyAdmin
if IsSuperAdmin(request=request):
from gvsdsdk.models import Company as SdkCompany
- return [c.CompanyUUID for c in SdkCompany.objects.filter(CompanyStatus=1)], 'all'
+ return [_ToBytes(c.CompanyUUID) for c in SdkCompany.objects.filter(CompanyStatus=1)], '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 [c.CompanyUUID for c in qs], 'tenant'
+ return [_ToBytes(c.CompanyUUID) for c in qs], 'tenant'
if IsCompanyAdmin(request=request):
# admin:company:取当前角色被授权公司(RolePermission.CompanyUUID 维度)
role = getattr(request, 'current_role', None)
@@ -503,6 +689,6 @@ class AppManager:
for rp in RolePermission.objects.filter(
RoleUUID=role.RoleUUID, PermUUID__in=perm_uuids):
if rp.CompanyUUID and rp.CompanyUUID != b'\x00' * 16:
- companies.append(rp.CompanyUUID)
+ companies.append(_ToBytes(rp.CompanyUUID))
return companies, 'company'
return [], ''
diff --git a/gvsds/apps/gerp/app_manifests.py b/gvsds/apps/gerp/app_manifests.py
new file mode 100644
index 00000000..1a5c2244
--- /dev/null
+++ b/gvsds/apps/gerp/app_manifests.py
@@ -0,0 +1,377 @@
+"""模块:应用清单(AppManifest)注册协议
+
+把"前端叫应用的这些"当成真正的应用去注册和处理——每个应用在代码中
+声明一份清单(Manifest),统一描述:
+ - 元数据 :AppCode/AppName/AppIcon/AppDesc/AppType/AuthRequired/排序
+ - 菜单协议 :安装后注入 GERP 侧边栏的菜单树(含挂载父与权限)
+ - 前端协议 :前端路由入口(vue 组件路由 / legacy 旧页面 iframe)
+ - 执行协议 :前端类型与入口页,配合 AppAccessPermission 统一鉴权
+
+注册流程:
+ 1. SyncAppManifests():按清单同步 App 表 + Menu 表(幂等 upsert),
+ 供管理命令 sync_apps 调用,亦可在部署后执行一次
+ 2. InjectAppMenus(AppCode):安装应用时激活菜单(MenuStatus=1)
+ 3. RemoveAppMenus(AppCode):卸载应用时移除菜单(MenuStatus=0)
+
+扩展(MSYNC 预留):
+ RegisterRemoteManifest():未来 MSYNC 子节点上报应用清单时,
+ 以同一 AppManifest 结构注册到远端注册中心,ListAppManifests()
+ 合并本地 + 远端清单统一下发。
+"""
+
+import uuid as _uuid
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from gvsdsdk.model_utils import UUIDObj
+from gvsdsdk.models import Menu as SdkMenu
+
+from .models import App
+
+
+@dataclass
+class AppMenuSpec:
+ """应用菜单节点声明(对应 Menu 表)
+
+ ParentMenuName 用父菜单的 MenuName 引用(应用菜单挂载点),
+ None 表示一级菜单。MenuName 为全局唯一标识,用作 upsert 定位键。
+ """
+
+ MenuName: str
+ MenuTitle: str
+ MenuLink: str = ''
+ MenuIcon: str = ''
+ MenuSort: int = 0
+ PermissionLevel: float = 1.0
+ PermissionAttr: str = ''
+ ParentMenuName: Optional[str] = None
+
+
+@dataclass
+class AppConfigSpec:
+ """应用配置键声明(对应 AppConfig 键值表)
+
+ 应用在 Manifest 中声明自己的配置模板:
+ - Key:配置键名(ConfigKey)
+ - Label:配置项显示名(配置页表单用)
+ - Default:默认值(未保存时回退)
+ - Secret:是否敏感(true 时前端脱敏显示,提交占位符视为未修改)
+ - Placeholder:输入提示(可选)
+ """
+
+ Key: str
+ Label: str = ''
+ Default: str = ''
+ Secret: bool = False
+ Placeholder: str = ''
+
+
+@dataclass
+class AppManifest:
+ """应用清单——真应用注册协议的数据载体"""
+
+ AppCode: str
+ AppName: str
+ AppIcon: str = ''
+ AppDesc: str = ''
+ AppType: str = 'enterprise' # enterprise/personal/both
+ AuthRequired: int = 0 # 0/1
+ AppSort: int = 0
+ DefaultPerms: str = '' # ; 分隔的默认权限码
+ FrontendRoute: str = '' # 前端路由(安装后"打开应用"跳转)
+ FrontendType: str = 'vue' # vue=前端组件 / legacy=旧页面 iframe
+ LegacySrc: str = '' # legacy 页面路径(pages/xxx.html)
+ Menus: List[AppMenuSpec] = field(default_factory=list)
+ ConfigKeys: List[AppConfigSpec] = field(default_factory=list)
+ Source: str = 'local' # local=本地清单 / msync=远端注册
+
+
+# ==================== 内置应用清单(local) ====================
+# 现状 6 个应用均为企业功能(enterprise),菜单挂载点保持与数据库现状一致。
+
+APP_MANIFESTS: List[AppManifest] = [
+ AppManifest(
+ AppCode='mailbox',
+ AppName='企业邮箱',
+ AppIcon='Message',
+ AppDesc='企业级邮件服务(Stalwart),支持多邮箱申请与安全浏览',
+ AppType='enterprise',
+ AppSort=1,
+ FrontendRoute='/mail/mailbox',
+ FrontendType='vue',
+ Menus=[
+ AppMenuSpec(
+ MenuName='mailbox', MenuTitle='我的邮箱',
+ MenuLink='pages/mail/mailbox.html', MenuIcon='Message',
+ MenuSort=3, ParentMenuName='myself',
+ ),
+ AppMenuSpec(
+ MenuName='mailbox-manage', MenuTitle='邮箱管理',
+ MenuLink='pages/mail/admin.html', MenuIcon='Message',
+ MenuSort=6, ParentMenuName='company',
+ ),
+ ],
+ ConfigKeys=[
+ AppConfigSpec(Key='SMTP_HOST', Label='SMTP 服务器', Placeholder='如 smtp.gvsds.com'),
+ AppConfigSpec(Key='SMTP_PORT', Label='SMTP 端口', Default='465'),
+ AppConfigSpec(Key='DOMAIN', Label='邮箱域名', Placeholder='如 gvsds.com'),
+ AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
+ ],
+ ),
+ AppManifest(
+ AppCode='gitea',
+ AppName='代码托管 Gitea',
+ AppIcon='Connection',
+ AppDesc='企业代码托管平台,支持免密登录与组织协作',
+ AppType='enterprise',
+ AppSort=2,
+ FrontendRoute='/gitea/mine',
+ FrontendType='vue',
+ Menus=[
+ AppMenuSpec(
+ MenuName='gitea-mine', MenuTitle='我的 Gitea',
+ MenuLink='pages/gitea/mine.html', MenuIcon='Connection',
+ MenuSort=4, ParentMenuName='myself',
+ ),
+ AppMenuSpec(
+ MenuName='gitea-admin', MenuTitle='Gitea 账号管理',
+ MenuLink='pages/gitea/admin.html', MenuIcon='Connection',
+ MenuSort=7, ParentMenuName='company',
+ ),
+ ],
+ ConfigKeys=[
+ AppConfigSpec(Key='ORG_NAME', Label='组织名', Placeholder='如 GVSDS'),
+ AppConfigSpec(Key='DOMAIN', Label='托管域名', Placeholder='如 git.gvsds.com'),
+ AppConfigSpec(Key='ALLOW_COMPANY', Label='可用公司(留空=全部公司)', Placeholder='公司UUID,多个用逗号分隔'),
+ ],
+ ),
+ AppManifest(
+ AppCode='shop',
+ AppName='枢机协律-商店',
+ AppIcon='ShoppingCart',
+ AppDesc='枢机协律-商店:企业内部商店与商城订单管理',
+ AppType='enterprise',
+ AppSort=3,
+ FrontendRoute='/private/CreateE/shop',
+ FrontendType='legacy',
+ LegacySrc='pages/private/CreateE/shop.html',
+ Menus=[
+ AppMenuSpec(
+ MenuName='create-e-shop', MenuTitle='商店',
+ MenuLink='pages/private/CreateE/shop.html',
+ MenuIcon='ShoppingCart', MenuSort=4, ParentMenuName='create-e',
+ ),
+ ],
+ ),
+ AppManifest(
+ AppCode='store-value',
+ AppName='枢机协律-储值',
+ AppIcon='Wallet',
+ AppDesc='枢机协律-储值:储值卡充值与管理',
+ AppType='enterprise',
+ AppSort=4,
+ FrontendRoute='/private/CreateE/StoreValue',
+ FrontendType='legacy',
+ LegacySrc='pages/private/CreateE/StoreValue.html',
+ Menus=[
+ AppMenuSpec(
+ MenuName='create-e-store-value', MenuTitle='储值查看',
+ MenuLink='pages/private/CreateE/StoreValue.html',
+ MenuIcon='Wallet', MenuSort=3, ParentMenuName='create-e',
+ ),
+ ],
+ ),
+ AppManifest(
+ AppCode='wallet',
+ AppName='枢机协律-支付系统',
+ AppIcon='Wallet',
+ AppDesc='枢机协律-支付系统:个人钱包账户与支付',
+ AppType='enterprise',
+ AppSort=5,
+ FrontendRoute='/private/CreateE/wallet',
+ FrontendType='legacy',
+ LegacySrc='pages/private/CreateE/wallet.html',
+ Menus=[
+ AppMenuSpec(
+ MenuName='create-e-wallet', MenuTitle='钱包',
+ MenuLink='pages/private/CreateE/wallet.html',
+ MenuIcon='Wallet', MenuSort=0, ParentMenuName='create-e',
+ ),
+ ],
+ ),
+ AppManifest(
+ AppCode='enterprise',
+ AppName='枢机协律-模拟企业',
+ AppIcon='OfficeBuilding',
+ AppDesc='枢机协律-模拟企业:企业信息管理(工商注册与企业详情)',
+ AppType='enterprise',
+ AppSort=6,
+ FrontendRoute='/private/CreateE/enterprise',
+ FrontendType='legacy',
+ LegacySrc='pages/private/CreateE/enterprise.html',
+ Menus=[
+ AppMenuSpec(
+ MenuName='create-e-enterprise', MenuTitle='企业管理',
+ MenuLink='pages/private/CreateE/enterprise.html',
+ MenuIcon='OfficeBuilding', MenuSort=1, ParentMenuName='create-e',
+ ),
+ ],
+ ),
+]
+
+# ==================== MSYNC 远端清单注册中心(预留) ====================
+
+_REMOTE_MANIFESTS: List[AppManifest] = []
+
+
+def RegisterRemoteManifest(manifest: AppManifest) -> None:
+ """注册 MSYNC 远端应用清单(未来子节点上报接入点)。
+
+ 与本地清单同构:AppCode 冲突时远端优先(避免覆盖已安装租户数据除外)。
+ """
+ for i, m in enumerate(_REMOTE_MANIFESTS):
+ if m.AppCode == manifest.AppCode:
+ _REMOTE_MANIFESTS[i] = manifest
+ return
+ _REMOTE_MANIFESTS.append(manifest)
+
+
+def ListAppManifests() -> List[AppManifest]:
+ """全部应用清单(本地 + MSYNC 远端)"""
+ return APP_MANIFESTS + _REMOTE_MANIFESTS
+
+
+def GetAppManifest(AppCode: str) -> Optional[AppManifest]:
+ for m in ListAppManifests():
+ if m.AppCode == AppCode:
+ return m
+ return None
+
+
+# ==================== 同步与注入 ====================
+
+def _ToBytes(value):
+ if value is None:
+ return None
+ if isinstance(value, bytes):
+ return value
+ if hasattr(value, 'bytes'):
+ return value.bytes
+ if isinstance(value, str):
+ try:
+ return UUIDObj(value).bytes
+ except Exception:
+ return None
+ return None
+
+
+def _UpsertMenu(spec: AppMenuSpec) -> SdkMenu:
+ """按 MenuName upsert 菜单(存在则更新字段,不存在则创建)。
+
+ MenuStatus 不在此覆盖(由 Inject/Remove 控制)。
+ """
+ menu = SdkMenu.objects.filter(MenuName=spec.MenuName).first()
+ # 解析挂载父(按父 MenuName 查)
+ parent_bytes = None
+ if spec.ParentMenuName:
+ parent = SdkMenu.objects.filter(MenuName=spec.ParentMenuName).first()
+ if parent is not None:
+ parent_bytes = parent.MenuUUID
+ if menu is None:
+ menu = SdkMenu(
+ MenuUUID=_uuid.uuid4().bytes,
+ MenuName=spec.MenuName,
+ )
+ menu.MenuTitle = spec.MenuTitle
+ menu.MenuLink = spec.MenuLink
+ menu.MenuIcon = spec.MenuIcon
+ menu.MenuSort = spec.MenuSort
+ menu.PermissionLevel = spec.PermissionLevel
+ menu.PermissionAttr = spec.PermissionAttr
+ menu.ParentMenuUUID = parent_bytes
+ menu.save()
+ return menu
+
+
+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.AppSort = manifest.AppSort
+ app.DefaultPerms = manifest.DefaultPerms
+ app.FrontendRoute = manifest.FrontendRoute
+ app.FrontendType = manifest.FrontendType
+ app.LegacySrc = manifest.LegacySrc
+ app.save()
+ return app
+
+
+def SyncAppManifests() -> List[str]:
+ """同步全部应用清单到 App 表(幂等)。
+
+ 仅同步 App 表元数据与前端入口;菜单不在此创建——
+ 菜单由安装状态驱动(InjectAppMenus 创建/回填,RemoveAppMenus 删除),
+ 不在 Menu 表中手工维护应用菜单项。
+
+ Returns:
+ 同步的应用 AppCode 列表
+ """
+ synced = []
+ for manifest in ListAppManifests():
+ app = _SyncAppRecord(manifest)
+ synced.append(manifest.AppCode)
+ return synced
+
+
+def _ManifestMenuNames(manifest: AppManifest) -> List[str]:
+ return [spec.MenuName for spec in manifest.Menus]
+
+
+def InjectAppMenus(AppCode: str) -> bool:
+ """安装应用:注入菜单(按 Manifest 创建/更新,MenuStatus=1),回填 MenuRootUUID
+
+ 菜单挂载点由 AppMenuSpec.ParentMenuName 声明(已知父菜单,如 myself/company),
+ 应用在安装时自动在父菜单下新增自己的页面。
+
+ Returns:
+ True=成功(含无菜单声明的应用)
+ """
+ manifest = GetAppManifest(AppCode)
+ if manifest is None:
+ return False
+ root_menu = None
+ for spec in manifest.Menus:
+ menu = _UpsertMenu(spec)
+ if menu.MenuStatus != 1:
+ menu.MenuStatus = 1
+ menu.save(update_fields=['MenuStatus'])
+ if root_menu is None:
+ root_menu = menu
+ # 回填 MenuRootUUID:指向第一个菜单节点(主入口,供前端打开应用/菜单过滤使用)
+ app = App.objects.filter(AppCode=AppCode).first()
+ if root_menu is not None and app is not None:
+ if app.MenuRootUUID != root_menu.MenuUUID:
+ app.MenuRootUUID = root_menu.MenuUUID
+ app.save(update_fields=['MenuRootUUID'])
+ return True
+
+
+def RemoveAppMenus(AppCode: str) -> bool:
+ """卸载应用:删除 Manifest 声明的全部菜单行(含散挂页面)
+
+ 菜单完全由 Manifest 管理:安装时注入创建,卸载时删除,
+ 不在 Menu 表中手工维护 gitea/mail 等应用菜单项。
+ """
+ manifest = GetAppManifest(AppCode)
+ if manifest is None:
+ return False
+ names = _ManifestMenuNames(manifest)
+ if names:
+ SdkMenu.objects.filter(MenuName__in=names).delete()
+ return True
diff --git a/gvsds/apps/gerp/management/commands/sync_apps.py b/gvsds/apps/gerp/management/commands/sync_apps.py
new file mode 100644
index 00000000..f8dd11a6
--- /dev/null
+++ b/gvsds/apps/gerp/management/commands/sync_apps.py
@@ -0,0 +1,73 @@
+"""管理命令:sync_apps
+
+真应用注册协议——把应用清单(Manifest)同步到 App 表,并按安装状态收敛菜单:
+- 同步 App 表:按清单 upsert 应用记录(元数据 + 前端入口字段)
+- 收敛菜单(默认执行):
+ - 应用存在启用中的安装/启用记录(租户/公司/个人任一)→ 注入菜单(创建+MenuStatus=1)
+ - 应用无任何启用记录 → 删除 Manifest 声明的菜单(清理手工遗留/卸载残留)
+ 菜单完全由 Manifest + 安装状态驱动,不在 Menu 表中手工维护应用菜单项。
+
+用法:
+ python manage.py sync_apps
+ python manage.py sync_apps --inject # 强制注入全部应用菜单(含未安装,调试用)
+ python manage.py sync_apps --no-menus # 仅同步 App 表,不动菜单
+"""
+
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ help = '把应用清单(Manifest)同步到 App 表 + 按安装状态收敛菜单(真应用注册协议)'
+
+ # 跳过系统检查:历史遗留的 E030/E012(SDK 双轨模型 index/unique 噪音)与本次无关
+ requires_system_checks = []
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--inject', action='store_true', dest='inject',
+ help='强制注入全部应用菜单(MenuStatus=1,含未安装,调试用)',
+ )
+ parser.add_argument(
+ '--no-menus', action='store_true', dest='no_menus',
+ help='仅同步 App 表,不动菜单',
+ )
+
+ def handle(self, *args, **options):
+ 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)}'))
+
+ if options['no_menus']:
+ return
+
+ if options['inject']:
+ # 调试模式:无条件注入全部应用菜单
+ for manifest in ListAppManifests():
+ if manifest.Menus:
+ InjectAppMenus(manifest.AppCode)
+ self.stdout.write(self.style.SUCCESS('已强制注入全部应用菜单(MenuStatus=1)'))
+ return
+
+ # 默认模式:按安装状态收敛菜单(安装注入 / 未安装删除)
+ injected, removed = [], []
+ for manifest in ListAppManifests():
+ app = App.objects.filter(AppCode=manifest.AppCode).first()
+ if app is None:
+ continue
+ from gvsds.apps.gerp.app_manager import AppManager
+ if AppManager._AnyEnabledInstall(app.AppUUID):
+ if manifest.Menus:
+ InjectAppMenus(manifest.AppCode)
+ injected.append(manifest.AppCode)
+ else:
+ if manifest.Menus:
+ RemoveAppMenus(manifest.AppCode)
+ removed.append(manifest.AppCode)
+ if injected:
+ self.stdout.write(self.style.SUCCESS(f'已注入菜单(有安装记录):{", ".join(injected)}'))
+ if removed:
+ self.stdout.write(self.style.SUCCESS(f'已删除菜单(无安装记录):{", ".join(removed)}'))
diff --git a/gvsds/apps/gerp/migrations/0025_app_frontend_fields.py b/gvsds/apps/gerp/migrations/0025_app_frontend_fields.py
new file mode 100644
index 00000000..83414984
--- /dev/null
+++ b/gvsds/apps/gerp/migrations/0025_app_frontend_fields.py
@@ -0,0 +1,33 @@
+"""Add App frontend entry fields (FrontendRoute/FrontendType/LegacySrc)
+
+真应用注册协议——应用声明前端入口:
+- FrontendRoute:前端路由路径(安装后"打开应用"跳转目标,如 /mail/mailbox)
+- FrontendType :前端类型(vue=前端组件 / legacy=旧版页面 iframe 直载)
+- LegacySrc :旧版页面路径(FrontendType=legacy 时生效,如 pages/private/CreateE/shop.html)
+"""
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('gerp', '0024_companyapp'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='App',
+ name='FrontendRoute',
+ field=models.CharField(db_column='FrontendRoute', default='', max_length=256, verbose_name='前端路由(安装后跳转)'),
+ ),
+ migrations.AddField(
+ model_name='App',
+ name='FrontendType',
+ field=models.CharField(db_column='FrontendType', default='vue', max_length=16, verbose_name='前端类型(vue/legacy)'),
+ ),
+ migrations.AddField(
+ model_name='App',
+ name='LegacySrc',
+ field=models.CharField(db_column='LegacySrc', default='', max_length=256, verbose_name='旧版页面路径(legacy)'),
+ ),
+ ]
diff --git a/gvsds/apps/gerp/migrations/0026_appconfig.py b/gvsds/apps/gerp/migrations/0026_appconfig.py
new file mode 100644
index 00000000..9c2be7bd
--- /dev/null
+++ b/gvsds/apps/gerp/migrations/0026_appconfig.py
@@ -0,0 +1,36 @@
+"""Add AppConfig (per-tenant app config key-value)
+
+应用配置键值表——按租户隔离的应用运行参数,供有安装权限者编辑。
+"""
+import datetime
+from django.db import migrations, models
+import gvsds.apps.core.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('gerp', '0025_app_frontend_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AppConfig',
+ fields=[
+ ('app_config_uuid', gvsds.apps.core.models.UUIDField(db_column='AppConfigUUID', primary_key=True, serialize=False, verbose_name='应用配置UUID')),
+ ('tenant_uuid', gvsds.apps.core.models.UUIDField(db_column='TenantUUID', verbose_name='租户UUID')),
+ ('app_uuid', gvsds.apps.core.models.UUIDField(db_column='AppUUID', verbose_name='应用UUID')),
+ ('config_key', models.CharField(db_column='ConfigKey', max_length=128, verbose_name='配置键名')),
+ ('config_value', models.TextField(db_column='ConfigValue', default='', verbose_name='配置值')),
+ ('update_by', gvsds.apps.core.models.UUIDField(blank=True, db_column='UpdateBy', null=True, verbose_name='更新人UUID')),
+ ('update_time', models.DateTimeField(blank=True, db_column='UpdateTime', null=True, verbose_name='更新时间')),
+ ('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')),
+ ],
+ options={
+ 'db_table': 'AppConfig',
+ 'verbose_name': '应用配置',
+ 'verbose_name_plural': '应用配置',
+ 'unique_together': {('tenant_uuid', 'app_uuid', 'config_key')},
+ },
+ ),
+ ]
diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py
index cb4a2427..559a1aa1 100644
--- a/gvsds/apps/gerp/models.py
+++ b/gvsds/apps/gerp/models.py
@@ -792,6 +792,9 @@ class App(QModel):
AuthRequired = models.SmallIntegerField(default=0, verbose_name='是否需企业授权(0/1)')
MenuRootUUID = UUIDField(null=True, blank=True, verbose_name='关联菜单根UUID(安装后注入)')
DefaultPerms = models.TextField(default='', verbose_name='默认权限码集(;分隔)')
+ FrontendRoute = models.CharField(max_length=256, default='', verbose_name='前端路由(安装后跳转)')
+ FrontendType = models.CharField(max_length=16, default='vue', verbose_name='前端类型(vue/legacy)')
+ LegacySrc = models.CharField(max_length=256, default='', verbose_name='旧版页面路径(legacy)')
AppSort = models.IntegerField(default=0, verbose_name='排序')
AppStatus = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
@@ -882,6 +885,34 @@ class UserApp(QModel):
verbose_name_plural = '个人应用'
+class AppConfig(QModel):
+ """应用配置键值表——按租户隔离的应用运行参数
+
+ 每个应用可声明若干配置键(ConfigKey),租户管理员/公司管理员
+ (有安装权限者)可在"配置"入口编辑本租户下的配置值。
+
+ 键值约定(由应用自行定义与读取,如 [stalwart] 域名映射):
+ ConfigKey 配置键名(如 SMTP_HOST / DOMAIN)
+ ConfigValue 配置值(自由文本)
+ """
+
+ AppConfigUUID = UUIDField(primary_key=True, verbose_name='应用配置UUID')
+ TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID')
+ AppUUID = UUIDField(db_column='AppUUID', verbose_name='应用UUID')
+ ConfigKey = models.CharField(max_length=128, verbose_name='配置键名')
+ ConfigValue = models.TextField(default='', verbose_name='配置值')
+ UpdateBy = UUIDField(null=True, blank=True, verbose_name='更新人UUID')
+ UpdateTime = models.DateTimeField(null=True, blank=True, verbose_name='更新时间')
+ CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
+
+ class Meta:
+ db_table = 'AppConfig'
+ app_label = 'gerp'
+ unique_together = (('tenant_uuid', 'app_uuid', 'config_key'),)
+ verbose_name = '应用配置'
+ verbose_name_plural = '应用配置'
+
+
# ==================== 从 gvsdsdk 导入重复模型 ====================
# 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库)
# 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问
diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py
index d38bfd74..a8230a25 100644
--- a/gvsds/apps/gerp/views/__init__.py
+++ b/gvsds/apps/gerp/views/__init__.py
@@ -155,6 +155,7 @@ from .app import (
AppApplyView,
AppApproveView,
AppPendingView,
+ AppConfigView,
)
from .system_ops import (
GERPSystemStatusView,
diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py
index 2999d0d6..24312e37 100644
--- a/gvsds/apps/gerp/views/app.py
+++ b/gvsds/apps/gerp/views/app.py
@@ -87,6 +87,22 @@ def _ParseUUIDList(values):
return result
+def _ToBytes(value):
+ """统一转 bytes(兼容 UUIDObj/bytes/str/uuid.UUID)"""
+ if value is None:
+ return None
+ if isinstance(value, bytes):
+ return value
+ if hasattr(value, 'bytes'):
+ return value.bytes
+ if isinstance(value, str):
+ try:
+ return UUIDObj(value).bytes
+ except Exception:
+ return None
+ return None
+
+
class AppMarketView(ELTAuthenticatedAPIView):
"""应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。"""
@@ -116,23 +132,37 @@ class AppMyAppsView(ELTAuthenticatedAPIView):
class AppManageCompaniesView(ELTAuthenticatedAPIView):
- """管理员可安装应用的公司列表(按权限范围:super=全部 / tenant=租户内 / company=被授权公司)"""
+ """管理员可安装应用的公司列表(按权限范围:super=全部 / tenant=租户内 / company=被授权公司)
+
+ GET /API/App/ManageCompanies
+ - 不传 AppUUID:返回全部可管理公司(安装弹窗用)
+ - 传 AppUUID :返回可管理公司 + 该应用在各公司的安装状态(管理弹窗用)
+ """
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
companies, scope = AppManager.GetManageableCompanies(request)
+ app_uuid_str = (request.query_params.get('AppUUID') or '').strip()
+ install_map = {}
+ if app_uuid_str:
+ install_map = AppManager.ListAppCompanyInstalls(
+ app_uuid_str, companies,
+ )
rows = []
for c in companies:
from gvsdsdk.models import Company as SdkCompany
company = SdkCompany.objects.filter(CompanyUUID=c).first()
if company is None:
continue
- rows.append({
+ row = {
'CompanyUUID': UUIDObj(company.CompanyUUID).str,
'CompanyName': company.CompanyName,
'CompanyShort': company.CompanyShort,
- })
+ }
+ if app_uuid_str:
+ row['Installed'] = 1 if install_map.get(c, 0) else 0
+ rows.append(row)
return Response({'code': 0, 'data': {'Scope': scope, 'Companies': rows}})
@@ -185,10 +215,11 @@ class AppInstallView(ELTAuthenticatedAPIView):
class AppUninstallView(ELTAuthenticatedAPIView):
"""管理员:停用企业功能。
- 支持两种粒度:
+ 支持三种粒度:
- 传 CompanyUUIDs → 停用指定公司安装(CompanyApp)
- - 不传 CompanyUUIDs → 停用全租户安装(TenantApp)+ 停用当前公司安装(CompanyApp)
- (前端停用当前公司功能时无需知道当前公司 UUID,后端按当前上下文双停)
+ - 传 TenantOnly=1 → 仅停用全租户安装(TenantApp),公司级记录不动
+ - 不传(或空)→ 停用全租户安装(TenantApp)+ 停用当前公司安装(CompanyApp)
+ (兼容旧调用:前端停用当前公司功能时双停)
"""
permission_classes = [IsAuthenticated, IsAdminUser]
@@ -198,8 +229,19 @@ class AppUninstallView(ELTAuthenticatedAPIView):
if not app_uuid:
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
+ tenant_only = request.data.get('TenantOnly')
+ if isinstance(tenant_only, str):
+ tenant_only = tenant_only.lower() in ('1', 'true', 'yes')
+
company_uuids = _ParseUUIDList(request.data.get('CompanyUUIDs'))
+ # 仅停用全租户安装(公司级记录不动)
+ if tenant_only:
+ tenant_bytes = _GetTenantBytes(request)
+ if tenant_bytes:
+ AppManager.UninstallTenantApp(tenant_bytes, app_uuid)
+ return Response({'code': 0, 'msg': '已停用全租户安装'})
+
# 停用指定公司安装
if company_uuids:
manageable, scope = AppManager.GetManageableCompanies(request)
@@ -312,3 +354,38 @@ class AppPendingView(ELTAuthenticatedAPIView):
return Response({'code': 0, 'data': {'list': [], 'total': 0}})
rows = AppManager.ListPendingApplications(tenant_bytes)
return Response({'code': 0, 'data': {'list': rows, 'total': len(rows)}})
+
+
+class AppConfigView(ELTAuthenticatedAPIView):
+ """应用配置(AppConfig 键值表)——有安装权限者(管理员)可读写
+
+ GET /API/App/Config?AppUUID=xxx — 返回该应用配置模板 + 当前租户已保存值
+ POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}}
+ """
+
+ permission_classes = [IsAuthenticated, IsAdminUser]
+
+ def get(self, request):
+ app_uuid = (request.query_params.get('AppUUID') or '').strip()
+ if not app_uuid:
+ return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
+ tenant_bytes = _GetTenantBytes(request)
+ rows = AppManager.GetAppConfig(tenant_bytes, app_uuid)
+ return Response({'code': 0, 'data': {'Configs': rows, 'Total': len(rows)}})
+
+ def post(self, request):
+ app_uuid = (request.data.get('AppUUID') or '').strip()
+ config = request.data.get('Config') or {}
+ if not app_uuid:
+ return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
+ if not isinstance(config, dict) or not config:
+ return Response({'code': 1, 'msg': '缺少配置项 Config'}, status=400)
+ tenant_bytes = _GetTenantBytes(request)
+ if not tenant_bytes:
+ return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400)
+ ok, err, updated = AppManager.SaveAppConfig(
+ tenant_bytes, app_uuid, config, OperatorUUID=_GetUserBytes(request),
+ )
+ if not ok:
+ return Response({'code': 1, 'msg': err}, status=400)
+ return Response({'code': 0, 'msg': f'已保存 {len(updated)} 项配置', 'data': {'Updated': updated}})
diff --git a/gvsds/apps/gerp/views/gitea.py b/gvsds/apps/gerp/views/gitea.py
index 63df37a1..854e2f79 100644
--- a/gvsds/apps/gerp/views/gitea.py
+++ b/gvsds/apps/gerp/views/gitea.py
@@ -285,7 +285,7 @@ class GiteaAvatarView(ELTAuthenticatedAPIView):
class GiteaAdminListView(ELTAuthenticatedAPIView):
"""管理员:全部员工 Gitea 账号列表(含用户名)。"""
- permission_classes = [IsAuthenticated, IsAdminUser]
+ permission_classes = [IsAuthenticated, IsAdminUser, _GITEA_ACCESS]
def get(self, request):
from gvsds.apps.core.models import User as CoreUser
@@ -318,7 +318,7 @@ class GiteaAdminListView(ELTAuthenticatedAPIView):
class GiteaDisableView(ELTAuthenticatedAPIView):
"""管理员:禁用账号(删除 Gitea 用户并置状态为 0)。"""
- permission_classes = [IsAuthenticated, IsAdminUser]
+ permission_classes = [IsAuthenticated, IsAdminUser, _GITEA_ACCESS]
def post(self, request):
acct_uuid = request.data.get('UserGiteaAccountUUID')
diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/views/mail.py
index 2a81f380..8b56378f 100644
--- a/gvsds/apps/gerp/views/mail.py
+++ b/gvsds/apps/gerp/views/mail.py
@@ -347,7 +347,7 @@ class MailMyMailboxView(ELTAuthenticatedAPIView):
class MailAdminListView(ELTAuthenticatedAPIView):
"""管理员:全部员工邮箱列表(含用户名)。"""
- permission_classes = [IsAuthenticated, IsAdminUser]
+ permission_classes = [IsAuthenticated, IsAdminUser, _MAIL_ACCESS]
def get(self, request):
from gvsds.apps.core.models import User as CoreUser
@@ -380,7 +380,7 @@ class MailAdminListView(ELTAuthenticatedAPIView):
class MailDisableView(ELTAuthenticatedAPIView):
"""管理员:禁用邮箱(删除 Stalwart 账户并置状态为 0)。"""
- permission_classes = [IsAuthenticated, IsAdminUser]
+ permission_classes = [IsAuthenticated, IsAdminUser, _MAIL_ACCESS]
def post(self, request):
from gvsdsdk.model_utils import UUIDObj as _UUIDObj
diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py
index 0ddaed90..49f2ac11 100644
--- a/gvsds/apps/gerp/views/menu.py
+++ b/gvsds/apps/gerp/views/menu.py
@@ -159,41 +159,47 @@ class MenuListCompatView(ELTAuthenticatedAPIView):
for uuid_str in no_perm_uuids:
menu_map.pop(uuid_str, None)
- # 应用/功能菜单过滤(仅普通用户):企业功能按租户安装状态、个人应用按个人启用状态
- # 管理员(admin:super/tenant)跳过过滤——管理视角需全览,便于在应用中心管理
- if not is_admin:
- try:
- from ..models import App
- from ..app_manager import AppManager
- # 当前租户 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()
- if _role_obj is not None:
- current_tenant_uuid = _role_obj.TenantUUID
- app_roots = [
- a for a in App.objects.filter(AppStatus=1, MenuRootUUID__isnull=False)
- ]
- for _app in app_roots:
- if AppManager.CheckAccess(
- current_tenant_uuid, user.UserUUID, _app.AppCode,
- CompanyUUID=current_company,
- ) is None:
- root_str = UUIDObj(_app.MenuRootUUID).str
- if root_str not in menu_map:
- continue
- _invalid = set()
- def _CollectAppSub(uuid_str, acc):
- acc.add(uuid_str)
- for uid, item in menu_map.items():
- if item['ParentUUID'] == uuid_str and uid not in acc:
- _CollectAppSub(uid, acc)
- _CollectAppSub(root_str, _invalid)
- for _u in _invalid:
- menu_map.pop(_u, None)
- except Exception:
- logger.exception('MenuListCompatView 应用菜单过滤失败')
+ # 应用/功能菜单过滤(严格收紧,所有用户含管理员):
+ # 未安装应用(租户/公司/个人均未启用)的 Manifest 声明的全部菜单一律隐藏,
+ # 含散挂在其他父节点下的页面(如"邮箱管理"挂公司下)。
+ # 管理员仅应用中心管理(安装/停用)豁免,业务菜单同样按安装状态隐藏。
+ try:
+ from ..models import App
+ from ..app_manager import AppManager
+ 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()
+ if _role_obj is not None:
+ current_tenant_uuid = _role_obj.TenantUUID
+
+ def _CollectAppSub(uuid_str, acc):
+ acc.add(uuid_str)
+ for uid, item in menu_map.items():
+ if item['ParentUUID'] == uuid_str and uid not in acc:
+ _CollectAppSub(uid, acc)
+
+ for _app in App.objects.filter(AppStatus=1):
+ if AppManager.CheckAccess(
+ current_tenant_uuid, user.UserUUID, _app.AppCode,
+ CompanyUUID=current_company,
+ ) is not None:
+ continue
+ manifest = GetAppManifest(_app.AppCode)
+ if manifest is None:
+ continue
+ # 收集该应用 Manifest 声明的全部菜单(MenuName 匹配 Id 字段)
+ app_menu_names = {s.MenuName for s in manifest.Menus}
+ _invalid = set()
+ for _uid, _item in menu_map.items():
+ if _item.get('Id') in app_menu_names:
+ _CollectAppSub(_uid, _invalid)
+ for _u in _invalid:
+ menu_map.pop(_u, None)
+ except Exception:
+ logger.exception('MenuListCompatView 应用菜单过滤失败')
# 构建树
root_menus = []
diff --git a/gvsds/urls.py b/gvsds/urls.py
index 51f10cd7..31229a95 100644
--- a/gvsds/urls.py
+++ b/gvsds/urls.py
@@ -157,6 +157,7 @@ urlpatterns = [
path('API/App/Apply', gerp_views.AppApplyView.as_view(), name='app_apply'),
path('API/App/Approve', gerp_views.AppApproveView.as_view(), name='app_approve'),
path('API/App/Pending', gerp_views.AppPendingView.as_view(), name='app_pending'),
+ path('API/App/Config', gerp_views.AppConfigView.as_view(), name='app_config'),
# 角色权限分配(权限树勾选)
path('API/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'),