feat(app): 应用配置公司化——AppConfig 增加 CompanyUUID(空=租户默认),读取回退链(公司→租户默认→模板默认),新增批量复制接口 Config/Copy,权限按管理范围校验
This commit is contained in:
1 parent
3fa8811a03
commit
b41d90c998
7 files changed
+227
-23
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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 = '应用配置'
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ from .app import (
|
||||
AppApproveView,
|
||||
AppPendingView,
|
||||
AppConfigView,
|
||||
AppConfigCopyView,
|
||||
)
|
||||
from .system_ops import (
|
||||
GERPSystemStatusView,
|
||||
|
||||
@@ -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}})
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user