Merge pull request #572 from wanxunyang/feature/app-share-wxy

feat: app sharing improvements - add response fields, fix cross-workspace copy & editable permission
This commit is contained in:
Mark
2026-03-17 10:47:50 +08:00
committed by GitHub
3 changed files with 381 additions and 19 deletions

View File

@@ -368,6 +368,10 @@ class App(BaseModel):
source_workspace_icon: Optional[str] = None # 共享来源工作空间图标 source_workspace_icon: Optional[str] = None # 共享来源工作空间图标
source_app_version: Optional[str] = None # 应用版本号 source_app_version: Optional[str] = None # 应用版本号
source_app_is_active: Optional[bool] = None # 应用是否生效 source_app_is_active: Optional[bool] = None # 应用是否生效
share_id: Optional[uuid.UUID] = None # 分享记录ID取消共享时使用
shared_by: Optional[uuid.UUID] = None # 分享者用户ID
shared_by_name: Optional[str] = None # 分享者名称
shared_at: Optional[datetime.datetime] = None # 分享时间
created_at: datetime.datetime created_at: datetime.datetime
updated_at: datetime.datetime updated_at: datetime.datetime
@@ -379,6 +383,10 @@ class App(BaseModel):
def _serialize_updated_at(self, dt: datetime.datetime): def _serialize_updated_at(self, dt: datetime.datetime):
return int(dt.timestamp() * 1000) if dt else None return int(dt.timestamp() * 1000) if dt else None
@field_serializer("shared_at", when_used="json")
def _serialize_shared_at(self, dt: Optional[datetime.datetime]):
return int(dt.timestamp() * 1000) if dt else None
class AgentConfig(BaseModel): class AgentConfig(BaseModel):
"""Agent 配置输出 Schema""" """Agent 配置输出 Schema"""

View File

@@ -11,6 +11,7 @@ from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException, ResourceNotFoundException from app.core.exceptions import BusinessException, ResourceNotFoundException
from app.models import AgentConfig, MultiAgentConfig from app.models import AgentConfig, MultiAgentConfig
from app.models.app_model import App, AppType from app.models.app_model import App, AppType
from app.models.appshare_model import AppShare
from app.models.app_release_model import AppRelease from app.models.app_release_model import AppRelease
from app.models.knowledge_model import Knowledge from app.models.knowledge_model import Knowledge
from app.models.models_model import ModelConfig from app.models.models_model import ModelConfig
@@ -298,11 +299,22 @@ class AppDslService:
return new_app, warnings return new_app, warnings
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
# 本空间自有应用名
existing = {r[0] for r in self.db.query(App.name).filter( existing = {r[0] for r in self.db.query(App.name).filter(
App.workspace_id == workspace_id, App.workspace_id == workspace_id,
App.type == app_type, App.type == app_type,
App.is_active.is_(True) App.is_active.is_(True)
).all()} ).all()}
# 共享到本空间的应用名
shared_names = {r[0] for r in self.db.query(App.name).join(
AppShare, AppShare.source_app_id == App.id
).filter(
AppShare.target_workspace_id == workspace_id,
App.type == app_type,
App.is_active.is_(True)
).all()}
existing |= shared_names
if name not in existing: if name not in existing:
return name return name
counter = 1 counter = 1

View File

@@ -7,6 +7,7 @@
- 应用发布和版本管理 - 应用发布和版本管理
- 应用回滚 - 应用回滚
""" """
import copy
import datetime import datetime
import uuid import uuid
from typing import Annotated, Any, Dict, List, Optional, Tuple from typing import Annotated, Any, Dict, List, Optional, Tuple
@@ -80,6 +81,8 @@ class AppService:
) )
raise BusinessException("应用不在指定工作空间中", BizCode.WORKSPACE_NO_ACCESS) raise BusinessException("应用不在指定工作空间中", BizCode.WORKSPACE_NO_ACCESS)
def _check_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> bool: def _check_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> bool:
"""检查应用是否可访问(包括共享应用) """检查应用是否可访问(包括共享应用)
@@ -126,6 +129,28 @@ class AppService:
) )
raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS) raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS)
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
existing = {r[0] for r in self.db.query(App.name).filter(
App.workspace_id == workspace_id,
App.type == app_type,
App.is_active.is_(True)
).all()}
shared_names = {r[0] for r in self.db.query(App.name).join(
AppShare, AppShare.source_app_id == App.id
).filter(
AppShare.target_workspace_id == workspace_id,
App.type == app_type,
App.is_active.is_(True)
).all()}
existing |= shared_names
if name not in existing:
return name
counter = 1
while f"{name}({counter})" in existing:
counter += 1
return f"{name}({counter})"
def _get_share_permission(self, app: App, workspace_id: Optional[uuid.UUID]) -> Optional[str]: def _get_share_permission(self, app: App, workspace_id: Optional[uuid.UUID]) -> Optional[str]:
"""获取共享应用的权限 """获取共享应用的权限
@@ -148,11 +173,11 @@ class AppService:
return share.permission if share else None return share.permission if share else None
def _validate_app_writable(self, app: App, workspace_id: Optional[uuid.UUID]) -> None: def _validate_app_writable(self, app: App, workspace_id: Optional[uuid.UUID]) -> None:
"""Validate that the app config is writable (owner only). """Validate that the app config is writable.
Shared apps (both readonly and editable) cannot modify config.
- Own workspace app: allowed - Own workspace app: allowed
- Any shared app: denied - Shared app with editable permission: allowed
- Shared app with readonly permission: denied
Raises: Raises:
BusinessException: when app is not writable BusinessException: when app is not writable
@@ -164,6 +189,11 @@ class AppService:
if app.workspace_id == workspace_id: if app.workspace_id == workspace_id:
return return
# Check share permission
permission = self._get_share_permission(app, workspace_id)
if permission == "editable":
return
logger.warning( logger.warning(
"应用写操作被拒", "应用写操作被拒",
extra={"app_id": str(app.id), "workspace_id": str(workspace_id)} extra={"app_id": str(app.id), "workspace_id": str(workspace_id)}
@@ -506,6 +536,10 @@ class AppService:
source_workspace_icon = None source_workspace_icon = None
source_app_version = None source_app_version = None
source_app_is_active = None source_app_is_active = None
share_id = None
shared_by = None
shared_by_name = None
shared_at = None
if is_shared: if is_shared:
# 查询共享权限和来源工作空间名称 # 查询共享权限和来源工作空间名称
@@ -517,7 +551,12 @@ class AppService:
) )
share = self.db.scalars(stmt).first() share = self.db.scalars(stmt).first()
if share: if share:
share_id = share.id
share_permission = share.permission share_permission = share.permission
shared_by = share.shared_by
shared_at = share.created_at
if share.shared_user:
shared_by_name = share.shared_user.username
if share.source_workspace: if share.source_workspace:
source_workspace_name = share.source_workspace.name source_workspace_name = share.source_workspace.name
source_workspace_icon = share.source_workspace.icon source_workspace_icon = share.source_workspace.icon
@@ -547,6 +586,10 @@ class AppService:
"source_workspace_icon": source_workspace_icon, "source_workspace_icon": source_workspace_icon,
"source_app_version": source_app_version, "source_app_version": source_app_version,
"source_app_is_active": source_app_is_active, "source_app_is_active": source_app_is_active,
"share_id": share_id,
"shared_by": shared_by,
"shared_by_name": shared_by_name,
"shared_at": shared_at,
"created_at": app.created_at, "created_at": app.created_at,
"updated_at": app.updated_at "updated_at": app.updated_at
} }
@@ -761,6 +804,7 @@ class AppService:
# 确定新应用名称 # 确定新应用名称
if not new_name: if not new_name:
new_name = f"{source_app.name} - 副本" new_name = f"{source_app.name} - 副本"
new_name = self._unique_app_name(new_name, target_workspace_id, source_app.type)
now = datetime.datetime.now() now = datetime.datetime.now()
@@ -784,6 +828,19 @@ class AppService:
self.db.add(new_app) self.db.add(new_app)
self.db.flush() self.db.flush()
# 判断是否跨工作空间复制(共享应用复制到自己的工作空间)
is_cross_workspace = target_workspace_id != source_app.workspace_id
# 跨工作空间时,获取目标工作空间的 tenant_id 用于判断模型配置是否可用
target_tenant_id = None
available_model_ids: set = set()
available_kb_ids: set = set()
if is_cross_workspace:
target_ws = self.db.get(Workspace, target_workspace_id)
if not target_ws:
raise ResourceNotFoundException("工作空间", str(target_workspace_id))
target_tenant_id = target_ws.tenant_id
# 如果是 agent 类型,复制 AgentConfig # 如果是 agent 类型,复制 AgentConfig
if source_app.type == AppType.AGENT: if source_app.type == AppType.AGENT:
source_config = self.db.query(AgentConfig).filter( source_config = self.db.query(AgentConfig).filter(
@@ -791,16 +848,40 @@ class AppService:
).first() ).first()
if source_config: if source_config:
if is_cross_workspace:
# Batch-collect and preload all referenced resources
model_ids, kb_ids = self._collect_resource_ids_from_config(
source_config.default_model_config_id,
source_config.knowledge_retrieval,
source_config.tools
)
available_model_ids, available_kb_ids = self._preload_cross_workspace_resources(
target_tenant_id, target_workspace_id, model_ids, kb_ids
)
new_model_config_id = self._is_model_available(
source_config.default_model_config_id, available_model_ids
)
new_knowledge_retrieval = self._clean_knowledge_retrieval(
source_config.knowledge_retrieval, available_kb_ids
)
new_tools = self._clean_tools(
source_config.tools, available_kb_ids
)
else:
new_model_config_id = source_config.default_model_config_id
new_knowledge_retrieval = copy.deepcopy(source_config.knowledge_retrieval) if source_config.knowledge_retrieval else None
new_tools = copy.deepcopy(source_config.tools) if source_config.tools else []
new_config = AgentConfig( new_config = AgentConfig(
id=uuid.uuid4(), id=uuid.uuid4(),
app_id=new_app.id, app_id=new_app.id,
system_prompt=source_config.system_prompt, system_prompt=source_config.system_prompt,
default_model_config_id=source_config.default_model_config_id, default_model_config_id=new_model_config_id,
model_parameters=source_config.model_parameters.copy() if source_config.model_parameters else None, model_parameters=copy.deepcopy(source_config.model_parameters) if source_config.model_parameters else None,
knowledge_retrieval=source_config.knowledge_retrieval.copy() if source_config.knowledge_retrieval else None, knowledge_retrieval=new_knowledge_retrieval,
memory=source_config.memory.copy() if source_config.memory else None, memory=copy.deepcopy(source_config.memory) if source_config.memory else None,
variables=source_config.variables.copy() if source_config.variables else [], variables=copy.deepcopy(source_config.variables) if source_config.variables else [],
tools=source_config.tools.copy() if source_config.tools else [], tools=new_tools,
is_active=True, is_active=True,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -813,14 +894,29 @@ class AppService:
).first() ).first()
if source_config: if source_config:
if is_cross_workspace:
model_ids, kb_ids = self._collect_resource_ids_from_workflow_nodes(
source_config.nodes
)
available_model_ids, available_kb_ids = self._preload_cross_workspace_resources(
target_tenant_id, target_workspace_id, model_ids, kb_ids
)
new_nodes = self._clean_workflow_nodes_for_cross_workspace(
source_config.nodes or [],
available_model_ids,
available_kb_ids
)
else:
new_nodes = copy.deepcopy(source_config.nodes) if source_config.nodes else []
new_config = WorkflowConfig( new_config = WorkflowConfig(
id=uuid.uuid4(), id=uuid.uuid4(),
app_id=new_app.id, app_id=new_app.id,
nodes=source_config.nodes.copy() if source_config.nodes else [], nodes=new_nodes,
edges=source_config.edges.copy() if source_config.edges else [], edges=copy.deepcopy(source_config.edges) if source_config.edges else [],
variables=source_config.variables.copy() if source_config.variables else [], variables=copy.deepcopy(source_config.variables) if source_config.variables else [],
execution_config=source_config.execution_config.copy() if source_config.execution_config else {}, execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {},
triggers=source_config.triggers.copy() if source_config.triggers else [], triggers=copy.deepcopy(source_config.triggers) if source_config.triggers else [],
is_active=True, is_active=True,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -833,17 +929,28 @@ class AppService:
).first() ).first()
if source_config: if source_config:
if is_cross_workspace:
model_ids = {source_config.default_model_config_id} if source_config.default_model_config_id else set()
available_model_ids, _ = self._preload_cross_workspace_resources(
target_tenant_id, target_workspace_id, model_ids, set()
)
new_model_config_id = self._is_model_available(
source_config.default_model_config_id, available_model_ids
)
else:
new_model_config_id = source_config.default_model_config_id
new_config = MultiAgentConfig( new_config = MultiAgentConfig(
id=uuid.uuid4(), id=uuid.uuid4(),
app_id=new_app.id, app_id=new_app.id,
master_agent_id=source_config.master_agent_id, master_agent_id=source_config.master_agent_id if not is_cross_workspace else None,
master_agent_name=source_config.master_agent_name, master_agent_name=source_config.master_agent_name,
default_model_config_id=source_config.default_model_config_id, default_model_config_id=new_model_config_id,
model_parameters=source_config.model_parameters, model_parameters=source_config.model_parameters,
orchestration_mode=source_config.orchestration_mode, orchestration_mode=source_config.orchestration_mode,
sub_agents=source_config.sub_agents.copy() if source_config.sub_agents else [], sub_agents=copy.deepcopy(source_config.sub_agents) if source_config.sub_agents else [],
routing_rules=source_config.routing_rules.copy() if source_config.routing_rules else None, routing_rules=copy.deepcopy(source_config.routing_rules) if source_config.routing_rules else None,
execution_config=source_config.execution_config.copy() if source_config.execution_config else {}, execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {},
aggregation_strategy=source_config.aggregation_strategy, aggregation_strategy=source_config.aggregation_strategy,
is_active=True, is_active=True,
created_at=now, created_at=now,
@@ -873,6 +980,241 @@ class AppService:
) )
raise BusinessException(f"应用复制失败: {str(e)}", BizCode.INTERNAL_ERROR, cause=e) raise BusinessException(f"应用复制失败: {str(e)}", BizCode.INTERNAL_ERROR, cause=e)
def _preload_cross_workspace_resources(
self,
target_tenant_id: Optional[uuid.UUID],
target_workspace_id: uuid.UUID,
model_config_ids: set,
kb_ids: set
) -> tuple:
"""Batch-load model configs and knowledge bases to avoid N+1 queries.
Returns:
(available_model_ids, available_kb_ids): sets of IDs available in target workspace
"""
from app.models.models_model import ModelConfig as MC
from app.models.knowledge_model import Knowledge
from app.models.knowledgeshare_model import KnowledgeShare
# Batch check model configs by tenant
available_model_ids: set = set()
if model_config_ids and target_tenant_id:
stmt = select(MC.id).where(
MC.id.in_(model_config_ids),
MC.tenant_id == target_tenant_id
)
available_model_ids = set(self.db.scalars(stmt).all())
# Batch check knowledge bases
available_kb_ids: set = set()
if kb_ids:
kb_uuids = set()
for kid in kb_ids:
try:
kb_uuids.add(uuid.UUID(str(kid)))
except (ValueError, AttributeError):
pass
if kb_uuids:
# KBs in target workspace
stmt = select(Knowledge.id).where(
Knowledge.id.in_(kb_uuids),
Knowledge.workspace_id == target_workspace_id
)
available_kb_ids.update(self.db.scalars(stmt).all())
# KBs shared to target workspace
remaining = kb_uuids - available_kb_ids
if remaining:
stmt = select(KnowledgeShare.source_kb_id).where(
KnowledgeShare.source_kb_id.in_(remaining),
KnowledgeShare.target_workspace_id == target_workspace_id
)
available_kb_ids.update(self.db.scalars(stmt).all())
return available_model_ids, available_kb_ids
@staticmethod
def _collect_resource_ids_from_config(
model_config_id: Optional[uuid.UUID],
knowledge_retrieval: Optional[dict],
tools: Optional[list]
) -> tuple:
"""Extract all model config IDs and knowledge base IDs from an app config."""
model_ids: set = set()
kb_ids: set = set()
if model_config_id:
model_ids.add(model_config_id)
if knowledge_retrieval and isinstance(knowledge_retrieval, dict):
if "kb_ids" in knowledge_retrieval:
for kid in knowledge_retrieval.get("kb_ids", []):
if kid:
kb_ids.add(str(kid))
if knowledge_retrieval.get("knowledge_id"):
kb_ids.add(str(knowledge_retrieval["knowledge_id"]))
if tools:
for tool in tools:
if isinstance(tool, dict):
kid = tool.get("knowledge_id") or tool.get("kb_id")
if kid:
kb_ids.add(str(kid))
return model_ids, kb_ids
@staticmethod
def _collect_resource_ids_from_workflow_nodes(nodes: list) -> tuple:
"""Extract all model config IDs and knowledge base IDs from workflow nodes."""
model_ids: set = set()
kb_ids: set = set()
for node in (nodes or []):
if not isinstance(node, dict):
continue
data = node.get("data", {})
if not isinstance(data, dict):
continue
for key in ("model_config_id", "default_model_config_id"):
val = data.get(key)
if val:
try:
model_ids.add(uuid.UUID(str(val)))
except (ValueError, AttributeError):
pass
kr = data.get("knowledge_retrieval")
if isinstance(kr, dict):
for kid in kr.get("kb_ids", []):
if kid:
kb_ids.add(str(kid))
if kr.get("knowledge_id"):
kb_ids.add(str(kr["knowledge_id"]))
if data.get("knowledge_id"):
kb_ids.add(str(data["knowledge_id"]))
for kid in data.get("kb_ids", []):
if kid:
kb_ids.add(str(kid))
return model_ids, kb_ids
@staticmethod
def _is_model_available(model_config_id: Optional[uuid.UUID], available_model_ids: set) -> Optional[uuid.UUID]:
if not model_config_id:
return None
return model_config_id if model_config_id in available_model_ids else None
@staticmethod
def _is_kb_available(kb_id: Optional[str], available_kb_ids: set) -> Optional[str]:
if not kb_id:
return None
try:
return kb_id if uuid.UUID(str(kb_id)) in available_kb_ids else None
except (ValueError, AttributeError):
return None
def _clean_knowledge_retrieval(
self,
knowledge_retrieval: Optional[dict],
available_kb_ids: set
) -> Optional[dict]:
"""Clean knowledge retrieval config, keeping only available KBs."""
if not knowledge_retrieval:
return None
cleaned = copy.deepcopy(knowledge_retrieval)
if "kb_ids" in cleaned and isinstance(cleaned["kb_ids"], list):
cleaned["kb_ids"] = [
kid for kid in cleaned["kb_ids"]
if self._is_kb_available(kid, available_kb_ids)
]
if "knowledge_id" in cleaned:
cleaned["knowledge_id"] = self._is_kb_available(
cleaned.get("knowledge_id"), available_kb_ids
)
return cleaned
def _clean_tools(
self,
tools: Optional[list],
available_kb_ids: set
) -> list:
"""Clean tools config, keeping built-in tools and tools with available KBs."""
if not tools:
return []
cleaned = []
for tool in tools:
if not isinstance(tool, dict):
cleaned.append(tool)
continue
tool_type = tool.get("type", "")
if tool_type in ("builtin", "built_in", "system"):
cleaned.append(copy.deepcopy(tool))
continue
kb_id = tool.get("knowledge_id") or tool.get("kb_id")
if kb_id:
if self._is_kb_available(kb_id, available_kb_ids):
cleaned.append(copy.deepcopy(tool))
continue
cleaned.append(copy.deepcopy(tool))
return cleaned
def _clean_workflow_nodes_for_cross_workspace(
self,
nodes: list,
available_model_ids: set,
available_kb_ids: set
) -> list:
"""Clean workflow nodes, using pre-loaded resource sets. Uses deepcopy to avoid mutating source."""
if not nodes:
return []
cleaned = []
for node in nodes:
if not isinstance(node, dict):
cleaned.append(node)
continue
node_copy = copy.deepcopy(node)
data = node_copy.get("data")
if not isinstance(data, dict):
cleaned.append(node_copy)
continue
for key in ("model_config_id", "default_model_config_id"):
if key in data and data[key]:
try:
mid = uuid.UUID(str(data[key]))
except (ValueError, AttributeError):
data[key] = None
continue
data[key] = str(mid) if mid in available_model_ids else None
if "knowledge_retrieval" in data and data["knowledge_retrieval"]:
data["knowledge_retrieval"] = self._clean_knowledge_retrieval(
data["knowledge_retrieval"], available_kb_ids
)
if "knowledge_id" in data:
data["knowledge_id"] = self._is_kb_available(
data.get("knowledge_id"), available_kb_ids
)
if "kb_ids" in data and isinstance(data["kb_ids"], list):
data["kb_ids"] = [
kid for kid in data["kb_ids"]
if self._is_kb_available(kid, available_kb_ids)
]
cleaned.append(node_copy)
return cleaned
def list_apps( def list_apps(
self, self,
*, *,