feat(app): 应用配置公司化——AppConfig 增加 CompanyUUID(空=租户默认),读取回退链(公司→租户默认→模板默认),新增批量复制接口 Config/Copy,权限按管理范围校验

This commit is contained in:
TermiNexus committed 2026-08-13 08:48:05 +08:00
1 parent 3fa8811a03
commit b41d90c998
7 files changed
+227 -23

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-4Rlj4UDe.js"></script>
<script type="module" crossorigin src="/assets/index-FiKDkpXQ.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-Bbr-sV49.css">
<link rel="stylesheet" crossorigin href="/assets/style-BVUAzsda.css">
</head>
<body>
<div id="app"></div>
+80 -8
View File
@@ -426,8 +426,15 @@ class AppManager:
]
@staticmethod
def GetAppConfig(TenantUUID, AppUUID):
"""读取指定租户下应用配置(模板 + 已保存值)
def GetAppConfig(TenantUUID, AppUUID, CompanyUUID=None):
"""读取指定租户(+可选公司)下应用配置(模板 + 已保存值)
读取回退链:公司级配置 → 租户默认配置 → 模板默认值。
Args:
TenantUUID: 租户 UUID
AppUUID: 应用 UUID
CompanyUUID: 公司 UUID(可选);传公司时优先读公司配置,缺失键回退租户默认
Returns:
list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated}
@@ -441,14 +448,21 @@ class AppManager:
return []
tenant_bytes = _ToBytes(TenantUUID)
app_bytes = _ToBytes(AppUUID)
saved = {}
company_bytes = _ToBytes(CompanyUUID)
saved_company = {}
if company_bytes:
for cfg in AppConfig.objects.filter(
TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=company_bytes):
saved_company[cfg.ConfigKey] = cfg
saved_tenant = {}
if tenant_bytes:
for cfg in AppConfig.objects.filter(TenantUUID=tenant_bytes, AppUUID=app_bytes):
saved[cfg.ConfigKey] = cfg
for cfg in AppConfig.objects.filter(
TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True):
saved_tenant[cfg.ConfigKey] = cfg
result = []
for t in template:
key = t['Key']
cfg = saved.get(key)
cfg = saved_company.get(key) or saved_tenant.get(key)
result.append({
'Key': key,
'Label': t['Label'],
@@ -461,13 +475,14 @@ class AppManager:
return result
@staticmethod
def SaveAppConfig(TenantUUID, AppUUID, Updates, OperatorUUID=None):
"""保存租户应用配置(按模板键 upsert)
def SaveAppConfig(TenantUUID, AppUUID, Updates, CompanyUUID=None, OperatorUUID=None):
"""保存应用配置(按模板键 upsert,支持租户默认/公司级)
Args:
TenantUUID: 租户 UUID
AppUUID: 应用 UUID
Updates: dict {Key: Value}
CompanyUUID: 公司 UUID(None=租户级默认配置)
OperatorUUID: 更新人 UUID(可选)
Returns:
@@ -483,6 +498,7 @@ class AppManager:
return False, '该应用未声明配置项', []
allowed = {t['Key'] for t in template}
tenant_bytes = _ToBytes(TenantUUID)
company_bytes = _ToBytes(CompanyUUID)
app_bytes = _ToBytes(AppUUID)
operator_bytes = _ToBytes(OperatorUUID)
updated = []
@@ -493,6 +509,7 @@ class AppManager:
value = str(value)
cfg, created = AppConfig.objects.get_or_create(
TenantUUID=tenant_bytes,
CompanyUUID=company_bytes,
AppUUID=app_bytes,
ConfigKey=key,
defaults={
@@ -510,6 +527,61 @@ class AppManager:
updated.append(key)
return True, '', updated
@staticmethod
def CopyAppConfig(TenantUUID, AppUUID, SourceCompanyUUID, TargetCompanyUUIDs, OperatorUUID=None):
"""把源配置(租户默认或指定公司)批量复制到目标公司(覆盖目标同名键)
Args:
TenantUUID: 租户 UUID
AppUUID: 应用 UUID
SourceCompanyUUID: 源公司 UUID(None=以租户默认配置为源)
TargetCompanyUUIDs: 目标公司 UUID 列表(str/bytes/UUIDObj 均可)
OperatorUUID: 复制人 UUID(可选)
Returns:
(ok, err, copied_count)
"""
import datetime as _dt
from .models import AppConfig
tenant_bytes = _ToBytes(TenantUUID)
app_bytes = _ToBytes(AppUUID)
source_bytes = _ToBytes(SourceCompanyUUID)
if source_bytes:
source_rows = list(AppConfig.objects.filter(
TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=source_bytes))
else:
source_rows = list(AppConfig.objects.filter(
TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True))
if not source_rows:
return False, '源配置不存在(无已保存的配置项)', 0
targets = [_ToBytes(c) for c in (TargetCompanyUUIDs or []) if _ToBytes(c)]
if not targets:
return False, '未指定目标公司', 0
operator_bytes = _ToBytes(OperatorUUID)
now = _dt.datetime.utcnow()
copied = 0
for company_bytes in targets:
for row in source_rows:
cfg, created = AppConfig.objects.get_or_create(
TenantUUID=tenant_bytes,
CompanyUUID=company_bytes,
AppUUID=app_bytes,
ConfigKey=row.ConfigKey,
defaults={
'AppConfigUUID': uuid.uuid4().bytes,
'ConfigValue': row.ConfigValue,
'UpdateBy': operator_bytes,
'UpdateTime': now,
},
)
if not created:
cfg.ConfigValue = row.ConfigValue
cfg.UpdateBy = operator_bytes
cfg.UpdateTime = now
cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime'])
copied += 1
return True, '', copied
# ==================== 个人启用/申请/审批 ====================
@staticmethod
@@ -0,0 +1,28 @@
"""Add CompanyUUID to AppConfig (per-company config, NULL = tenant default)
应用配置从租户级扩展为公司级:
- CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享)
- CompanyUUID 有值 → 公司级配置(覆盖租户默认)
唯一约束扩为 (tenant, company, app, key),MySQL BINARY(16) NULL 允许多条租户默认。
"""
from django.db import migrations
import gvsds.apps.core.models
class Migration(migrations.Migration):
dependencies = [
('gerp', '0026_appconfig'),
]
operations = [
migrations.AddField(
model_name='appconfig',
name='company_uuid',
field=gvsds.apps.core.models.UUIDField(blank=True, db_column='CompanyUUID', null=True, verbose_name='公司UUID(空=租户级默认配置)'),
),
migrations.AlterUniqueTogether(
name='appconfig',
unique_together={('tenant_uuid', 'company_uuid', 'app_uuid', 'config_key')},
),
]
+13 -7
View File
@@ -829,7 +829,7 @@ class TenantApp(QModel):
class Meta:
db_table = 'TenantApp'
app_label = 'gerp'
unique_together = (('tenant_uuid', 'app_uuid'),)
unique_together = (('TenantUUID', 'AppUUID'),)
verbose_name = '企业应用安装'
verbose_name_plural = '企业应用安装'
@@ -855,7 +855,7 @@ class CompanyApp(QModel):
class Meta:
db_table = 'CompanyApp'
app_label = 'gerp'
unique_together = (('company_uuid', 'app_uuid'),)
unique_together = (('CompanyUUID', 'AppUUID'),)
verbose_name = '企业应用安装(公司维度)'
verbose_name_plural = '企业应用安装(公司维度)'
@@ -880,16 +880,21 @@ class UserApp(QModel):
class Meta:
db_table = 'UserApp'
app_label = 'gerp'
unique_together = (('user_uuid', 'app_uuid'),)
unique_together = (('UserUUID', 'AppUUID'),)
verbose_name = '个人应用'
verbose_name_plural = '个人应用'
class AppConfig(QModel):
"""应用配置键值表——按租户隔离的应用运行参数
"""应用配置键值表——按租户+公司隔离的应用运行参数
每个应用可声明若干配置键(ConfigKey),租户管理员/公司管理员
(有安装权限者)可在"配置"入口编辑本租户下的配置值。
每个应用可声明若干配置键(ConfigKey),管理员(有安装权限者)可在
"配置"入口编辑配置值。
配置粒度:
- CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享)
- CompanyUUID 有值 → 公司级配置(覆盖租户默认)
读取回退链:公司级 → 租户默认 → 模板默认值。
键值约定(由应用自行定义与读取,如 [stalwart] 域名映射):
ConfigKey 配置键名(如 SMTP_HOST / DOMAIN)
@@ -898,6 +903,7 @@ class AppConfig(QModel):
AppConfigUUID = UUIDField(primary_key=True, verbose_name='应用配置UUID')
TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID')
CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', 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='配置值')
@@ -908,7 +914,7 @@ class AppConfig(QModel):
class Meta:
db_table = 'AppConfig'
app_label = 'gerp'
unique_together = (('tenant_uuid', 'app_uuid', 'config_key'),)
unique_together = (('TenantUUID', 'CompanyUUID', 'AppUUID', 'ConfigKey'),)
verbose_name = '应用配置'
verbose_name_plural = '应用配置'
+1
View File
@@ -156,6 +156,7 @@ from .app import (
AppApproveView,
AppPendingView,
AppConfigView,
AppConfigCopyView,
)
from .system_ops import (
GERPSystemStatusView,
+102 -6
View File
@@ -104,6 +104,34 @@ def _ToBytes(value):
return None
def _ResolveConfigCompany(request, company_uuid_str):
"""解析应用配置目标范围并校验权限(租户默认/公司级)
规则:
- 不传公司 UUID:租户管理员(scope all/tenant)→ 租户默认;公司管理员 → 默认本公司
- 传公司 UUID :租户管理员可任意;公司管理员仅授权公司
Returns:
(company_bytes_or_None, err);err 非空表示拒绝
"""
manageable, scope = AppManager.GetManageableCompanies(request)
if not company_uuid_str:
if scope in ('all', 'tenant'):
return None, None
if manageable:
return manageable[0], None
return None, '您没有可管理的公司'
target = _ParseUUIDList([company_uuid_str])
if not target:
return None, '无效的公司 UUID'
if scope in ('all', 'tenant'):
return target[0], None
manageable_set = {c.hex() for c in manageable}
if target[0].hex() in manageable_set:
return target[0], None
return None, '无权管理该公司的配置'
class AppMarketView(ELTAuthenticatedAPIView):
"""应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。"""
@@ -363,10 +391,15 @@ class AppPendingView(ELTAuthenticatedAPIView):
class AppConfigView(ELTAuthenticatedAPIView):
"""应用配置(AppConfig 键值表)——有安装权限者(管理员)可读写
"""应用配置(租户默认/公司级)
GET /API/App/Config?AppUUID=xxx — 返回该应用配置模板 + 当前租户已保存值
POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}}
GET /API/App/Config?AppUUID=xxx[&CompanyUUID=yyy]
- 返回该应用配置模板 + 当前范围已保存值(公司缺失键回退租户默认)
- 不传 CompanyUUID = 租户默认配置(全租户共享)
- 租户管理员可传任意公司;公司管理员只能读本公司(不传时默认本公司)
POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}, CompanyUUID?}
- 保存到指定范围(CompanyUUID 空=租户默认)
- 租户管理员可写任意范围;公司管理员只能写本公司
"""
permission_classes = [IsAuthenticated, IsAdminUser]
@@ -375,9 +408,18 @@ class AppConfigView(ELTAuthenticatedAPIView):
app_uuid = (request.query_params.get('AppUUID') or '').strip()
if not app_uuid:
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
company_str = (request.query_params.get('CompanyUUID') or '').strip()
company_bytes, err = _ResolveConfigCompany(request, company_str)
if err:
return Response({'code': 403, 'msg': err}, status=403)
tenant_bytes = _GetTenantBytes(request)
rows = AppManager.GetAppConfig(tenant_bytes, app_uuid)
return Response({'code': 0, 'data': {'Configs': rows, 'Total': len(rows)}})
rows = AppManager.GetAppConfig(tenant_bytes, app_uuid, company_bytes)
return Response({'code': 0, 'data': {
'Configs': rows,
'Total': len(rows),
'Scope': 'company' if company_bytes else 'tenant',
'CompanyUUID': UUIDObj(company_bytes).str if company_bytes else '',
}})
def post(self, request):
app_uuid = (request.data.get('AppUUID') or '').strip()
@@ -386,12 +428,66 @@ class AppConfigView(ELTAuthenticatedAPIView):
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
if not isinstance(config, dict) or not config:
return Response({'code': 1, 'msg': '缺少配置项 Config'}, status=400)
company_str = (request.data.get('CompanyUUID') or '').strip()
company_bytes, err = _ResolveConfigCompany(request, company_str)
if err:
return Response({'code': 403, 'msg': err}, status=403)
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),
tenant_bytes, app_uuid, config,
CompanyUUID=company_bytes,
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}})
class AppConfigCopyView(ELTAuthenticatedAPIView):
"""配置批量复制——把源配置(租户默认/某公司)复制到目标公司(覆盖同名键)
POST /API/App/Config/Copy — Body: {AppUUID, SourceCompanyUUID?, TargetCompanyUUIDs[]}
- SourceCompanyUUID 不传 = 以租户默认配置为源
- 权限:租户管理员任意;公司管理员仅可复制到本公司且源必须是本公司/租户默认
"""
permission_classes = [IsAuthenticated, IsAdminUser]
def post(self, request):
app_uuid = (request.data.get('AppUUID') or '').strip()
if not app_uuid:
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
targets = _ParseUUIDList(request.data.get('TargetCompanyUUIDs'))
if not targets:
return Response({'code': 1, 'msg': '未指定目标公司 TargetCompanyUUIDs'}, status=400)
tenant_bytes = _GetTenantBytes(request)
if not tenant_bytes:
return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400)
# 权限校验:租户管理员(scope all/tenant)任意;公司管理员(scope company)仅本公司
manageable, scope = AppManager.GetManageableCompanies(request)
if scope not in ('all', 'tenant'):
manageable_set = {c.hex() for c in manageable}
invalid = [t for t in targets if t.hex() not in manageable_set]
if invalid:
return Response({'code': 403, 'msg': '无权复制到以下公司(不在您的管理范围)'}, status=403)
source_bytes = None
source_str = (request.data.get('SourceCompanyUUID') or '').strip()
if source_str:
src = _ParseUUIDList([source_str])
if not src:
return Response({'code': 1, 'msg': '无效的源公司 UUID'}, status=400)
source_bytes = src[0]
if scope not in ('all', 'tenant') and source_bytes.hex() not in manageable_set:
return Response({'code': 403, 'msg': '无权使用该公司的配置作为源'}, status=403)
ok, err, copied = AppManager.CopyAppConfig(
tenant_bytes, app_uuid, source_bytes, targets,
OperatorUUID=_GetUserBytes(request),
)
if not ok:
return Response({'code': 1, 'msg': err}, status=400)
return Response({'code': 0, 'msg': f'已复制 {copied} 项配置到 {len(targets)} 家公司', 'data': {'Copied': copied}})
+1
View File
@@ -158,6 +158,7 @@ urlpatterns = [
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/App/Config/Copy', gerp_views.AppConfigCopyView.as_view(), name='app_config_copy'),
# 角色权限分配(权限树勾选)
path('API/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'),