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'),