feat(gerp): 真应用注册协议 Manifest + 严格可用收紧 + 应用配置

- 新增 app_manifests.py:应用清单协议(元数据/菜单/前端入口/配置模板),内置6应用
- 菜单由 Manifest 全权管理:安装注入(挂已知父菜单)/卸载删除,删除手工 menu 项
- App 表新增 FrontendRoute/FrontendType/LegacySrc(0025);新增 AppConfig 键值表(0026)
- app_manager 配置服务(模板/读取/保存)+ AppConfigView(/API/App/Config)
- 严格收紧:AppAccessPermission 移除管理员豁免、menu 过滤全员执行按 Manifest 全菜单
- mail/gitea 管理视图补挂应用权限;Uninstall 支持 TenantOnly
- ManageCompanies 支持按应用查公司安装状态;修复 UUIDObj 无 .hex(转 bytes)
- 新增 sync_apps 管理命令(按安装状态收敛菜单)
This commit is contained in:
TermiNexus committed 2026-08-13 07:47:07 +08:00
1 parent 4de301aa78
commit 06d43b5a6e
14 files changed
+875 -56

No files matched your search

+2 -2
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GERP 管理系统</title>
<script type="module" crossorigin src="/assets/index-DxQCUaG4.js"></script>
<script type="module" crossorigin src="/assets/index-BCZ_yh-A.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DtMR4CIi.js">
<link rel="modulepreload" crossorigin href="/assets/utils-C0vl14oO.js">
@@ -14,7 +14,7 @@
<link rel="modulepreload" crossorigin href="/assets/formkit-DL7Nej8X.js">
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-DmO0PmrK.js">
<link rel="modulepreload" crossorigin href="/assets/serverScope-C6yhSV7-.js">
<link rel="stylesheet" crossorigin href="/assets/style-Xc5aXuRA.css">
<link rel="stylesheet" crossorigin href="/assets/style-C-s7UXLv.css">
</head>
<body>
<div id="app"></div>
+4 -6
View File
@@ -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)
+189 -3
View File
@@ -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 [], ''
+377
View File
@@ -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
@@ -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)}'))
@@ -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)'),
),
]
@@ -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')},
},
),
]
+31
View File
@@ -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 和关联属性访问
+1
View File
@@ -155,6 +155,7 @@ from .app import (
AppApplyView,
AppApproveView,
AppPendingView,
AppConfigView,
)
from .system_ops import (
GERPSystemStatusView,
+83 -6
View File
@@ -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}})
+2 -2
View File
@@ -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')
+2 -2
View File
@@ -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
+41 -35
View File
@@ -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 = []
+1
View File
@@ -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'),