feat(app): add cross-workspace app sharing backend

This commit is contained in:
wxy
2026-03-13 10:26:59 +08:00
parent f0c3d5f308
commit d66b9dd8cb
7 changed files with 726 additions and 490 deletions

View File

@@ -60,7 +60,12 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako # are written from script.py.mako
# output_encoding = utf-8 # output_encoding = utf-8
sqlalchemy.url = postgresql://user:password@localhost/dbname # Database connection URL - DO NOT hardcode credentials here!
# Connection string is set dynamically from environment variables in migrations/env.py
# Required env vars: DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME
# Example: postgresql://user:password@localhost:5432/dbname
; sqlalchemy.url = postgresql://user:password@host:port/dbname
sqlalchemy.url = driver://user:password@host:port/dbname
[post_write_hooks] [post_write_hooks]

View File

@@ -93,6 +93,20 @@ def list_apps(
return success(data=PageData(page=meta, items=items)) return success(data=PageData(page=meta, items=items))
@router.get("/my-shared-out", summary="列出本工作空间主动分享出去的记录")
@cur_workspace_access_guard()
def list_my_shared_out(
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""列出本工作空间主动分享给其他工作空间的所有记录(我的共享)"""
workspace_id = current_user.current_workspace_id
service = app_service.AppService(db)
shares = service.list_my_shared_out(workspace_id=workspace_id)
data = [app_schema.AppShare.model_validate(s) for s in shares]
return success(data=data)
@router.get("/{app_id}", summary="获取应用详情") @router.get("/{app_id}", summary="获取应用详情")
@cur_workspace_access_guard() @cur_workspace_access_guard()
def get_app( def get_app(
@@ -302,7 +316,8 @@ def share_app(
app_id=app_id, app_id=app_id,
target_workspace_ids=payload.target_workspace_ids, target_workspace_ids=payload.target_workspace_ids,
user_id=current_user.id, user_id=current_user.id,
workspace_id=workspace_id workspace_id=workspace_id,
permission=payload.permission
) )
data = [app_schema.AppShare.model_validate(s) for s in shares] data = [app_schema.AppShare.model_validate(s) for s in shares]
@@ -333,6 +348,32 @@ def unshare_app(
return success(msg="应用分享已取消") return success(msg="应用分享已取消")
@router.patch("/{app_id}/share/{target_workspace_id}", summary="更新共享权限")
@cur_workspace_access_guard()
def update_share_permission(
app_id: uuid.UUID,
target_workspace_id: uuid.UUID,
payload: app_schema.UpdateSharePermissionRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""更新共享权限readonly <-> editable
- 只能修改自己工作空间应用的共享权限
"""
workspace_id = current_user.current_workspace_id
service = app_service.AppService(db)
share = service.update_share_permission(
app_id=app_id,
target_workspace_id=target_workspace_id,
permission=payload.permission,
workspace_id=workspace_id
)
return success(data=app_schema.AppShare.model_validate(share))
@router.get("/{app_id}/shares", summary="列出应用的分享记录") @router.get("/{app_id}/shares", summary="列出应用的分享记录")
@cur_workspace_access_guard() @cur_workspace_access_guard()
def list_app_shares( def list_app_shares(
@@ -356,6 +397,29 @@ def list_app_shares(
return success(data=data) return success(data=data)
@router.delete("/{app_id}/shared", summary="移除共享给我的应用")
@cur_workspace_access_guard()
def remove_shared_app(
app_id: uuid.UUID,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""被共享者从自己的工作空间移除共享应用
- 不会删除源应用,只删除共享记录
- 只能移除共享给自己工作空间的应用
"""
workspace_id = current_user.current_workspace_id
service = app_service.AppService(db)
service.remove_shared_app(
app_id=app_id,
workspace_id=workspace_id
)
return success(msg="已移除共享应用")
@router.post("/{app_id}/draft/run", summary="试运行 Agent使用当前草稿配置") @router.post("/{app_id}/draft/run", summary="试运行 Agent使用当前草稿配置")
@cur_workspace_access_guard() @cur_workspace_access_guard()
async def draft_run( async def draft_run(

View File

@@ -1,6 +1,6 @@
import datetime import datetime
import uuid import uuid
from sqlalchemy import Column, DateTime, ForeignKey from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from app.db import Base from app.db import Base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -18,6 +18,7 @@ class AppShare(Base):
source_workspace_id = Column(UUID(as_uuid=True), ForeignKey('workspaces.id'), nullable=False, comment="源工作空间ID") source_workspace_id = Column(UUID(as_uuid=True), ForeignKey('workspaces.id'), nullable=False, comment="源工作空间ID")
target_workspace_id = Column(UUID(as_uuid=True), ForeignKey('workspaces.id'), nullable=False, comment="目标工作空间ID") target_workspace_id = Column(UUID(as_uuid=True), ForeignKey('workspaces.id'), nullable=False, comment="目标工作空间ID")
shared_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False, comment="分享者用户ID") shared_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False, comment="分享者用户ID")
permission = Column(String, default="readonly", nullable=False, comment="权限模式: readonly | editable")
created_at = Column(DateTime, default=datetime.datetime.now) created_at = Column(DateTime, default=datetime.datetime.now)
updated_at = Column(DateTime, default=datetime.datetime.now) updated_at = Column(DateTime, default=datetime.datetime.now)

View File

@@ -277,7 +277,12 @@ class App(BaseModel):
tags: List[str] = [] tags: List[str] = []
current_release_id: Optional[uuid.UUID] = None current_release_id: Optional[uuid.UUID] = None
is_active: bool is_active: bool
is_shared: bool = False # 是否是共享应用(从其他工作空间共享来的) is_shared: bool = False
share_permission: Optional[str] = None
source_workspace_name: Optional[str] = None # 共享来源工作空间名称(仅共享应用有值)
source_workspace_icon: Optional[str] = None # 共享来源工作空间图标
source_app_version: Optional[str] = None # 应用版本号
source_app_is_active: Optional[bool] = None # 应用是否生效
created_at: datetime.datetime created_at: datetime.datetime
updated_at: datetime.datetime updated_at: datetime.datetime
@@ -422,6 +427,12 @@ class AppRelease(BaseModel):
class AppShareCreate(BaseModel): class AppShareCreate(BaseModel):
"""应用分享请求""" """应用分享请求"""
target_workspace_ids: List[uuid.UUID] = Field(..., description="目标工作空间ID列表") target_workspace_ids: List[uuid.UUID] = Field(..., description="目标工作空间ID列表")
permission: str = Field(default="readonly", description="权限模式: readonly | editable")
class UpdateSharePermissionRequest(BaseModel):
"""更新共享权限请求"""
permission: str = Field(..., description="新权限值: readonly | editable")
class AppShare(BaseModel): class AppShare(BaseModel):
@@ -433,9 +444,32 @@ class AppShare(BaseModel):
source_workspace_id: uuid.UUID source_workspace_id: uuid.UUID
target_workspace_id: uuid.UUID target_workspace_id: uuid.UUID
shared_by: uuid.UUID shared_by: uuid.UUID
permission: str = "readonly"
created_at: datetime.datetime created_at: datetime.datetime
updated_at: datetime.datetime updated_at: datetime.datetime
# 关联名称(从 relationship 读取)
source_app_name: Optional[str] = None
source_app_type: Optional[str] = None
source_app_version: Optional[str] = None
source_app_is_active: Optional[bool] = None
target_workspace_name: Optional[str] = None
target_workspace_icon: Optional[str] = None
@classmethod
def model_validate(cls, obj, **kwargs):
instance = super().model_validate(obj, **kwargs)
if hasattr(obj, 'source_app') and obj.source_app:
instance.source_app_name = obj.source_app.name
instance.source_app_type = obj.source_app.type
instance.source_app_is_active = obj.source_app.is_active
release = obj.source_app.current_release
instance.source_app_version = release.version_name if release else None
if hasattr(obj, 'target_workspace') and obj.target_workspace:
instance.target_workspace_name = obj.target_workspace.name
instance.target_workspace_icon = obj.target_workspace.icon
return instance
@field_serializer("created_at", when_used="json") @field_serializer("created_at", when_used="json")
def _serialize_created_at(self, dt: datetime.datetime): def _serialize_created_at(self, dt: datetime.datetime):
return int(dt.timestamp() * 1000) if dt else None return int(dt.timestamp() * 1000) if dt else None

View File

@@ -125,6 +125,49 @@ class AppService:
) )
raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS) raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS)
def _get_share_permission(self, app: App, workspace_id: Optional[uuid.UUID]) -> Optional[str]:
"""获取共享应用的权限
Returns:
None: 不是共享应用(是本工作空间的应用)
'readonly': 只读共享
'editable': 可编辑共享
"""
from app.models import AppShare
if workspace_id is None or app.workspace_id == workspace_id:
return None # 本工作空间的应用,不是共享的
stmt = select(AppShare).where(
AppShare.source_app_id == app.id,
AppShare.target_workspace_id == workspace_id
)
share = self.db.scalars(stmt).first()
return share.permission if share else None
def _validate_app_writable(self, app: App, workspace_id: Optional[uuid.UUID]) -> None:
"""Validate that the app config is writable (owner only).
Shared apps (both readonly and editable) cannot modify config.
- Own workspace app: allowed
- Any shared app: denied
Raises:
BusinessException: when app is not writable
"""
if workspace_id is None:
return
# Own workspace app, allow
if app.workspace_id == workspace_id:
return
logger.warning(
"应用写操作被拒",
extra={"app_id": str(app.id), "workspace_id": str(workspace_id)}
)
raise BusinessException("共享应用不可修改配置", BizCode.WORKSPACE_NO_ACCESS)
def _get_app_or_404(self, app_id: uuid.UUID) -> App: def _get_app_or_404(self, app_id: uuid.UUID) -> App:
"""获取应用或抛出404异常 """获取应用或抛出404异常
@@ -454,6 +497,32 @@ class AppService:
Returns: Returns:
app_schema.App: 应用 Schema app_schema.App: 应用 Schema
""" """
is_shared = app.workspace_id != current_workspace_id
share_permission = None
source_workspace_name = None
source_workspace_icon = None
source_app_version = None
source_app_is_active = None
if is_shared:
# 查询共享权限和来源工作空间名称
from app.models import AppShare
stmt = select(AppShare).where(
AppShare.source_app_id == app.id,
AppShare.target_workspace_id == current_workspace_id
)
share = self.db.scalars(stmt).first()
if share:
share_permission = share.permission
if share.source_workspace:
source_workspace_name = share.source_workspace.name
source_workspace_icon = share.source_workspace.icon
# 版本号和生效状态
if app.current_release:
source_app_version = app.current_release.version_name
source_app_is_active = app.is_active
app_dict = { app_dict = {
"id": app.id, "id": app.id,
"workspace_id": app.workspace_id, "workspace_id": app.workspace_id,
@@ -468,7 +537,12 @@ class AppService:
"tags": app.tags or [], "tags": app.tags or [],
"current_release_id": app.current_release_id, "current_release_id": app.current_release_id,
"is_active": app.is_active, "is_active": app.is_active,
"is_shared": app.workspace_id != current_workspace_id, # 判断是否是共享应用 "is_shared": is_shared,
"share_permission": share_permission,
"source_workspace_name": source_workspace_name,
"source_workspace_icon": source_workspace_icon,
"source_app_version": source_app_version,
"source_app_is_active": source_app_is_active,
"created_at": app.created_at, "created_at": app.created_at,
"updated_at": app.updated_at "updated_at": app.updated_at
} }
@@ -594,7 +668,7 @@ class AppService:
logger.info("更新应用", extra={"app_id": str(app_id)}) logger.info("更新应用", extra={"app_id": str(app_id)})
app = self._get_app_or_404(app_id) app = self._get_app_or_404(app_id)
self._validate_workspace_access(app, workspace_id) self._validate_app_writable(app, workspace_id)
changed = False changed = False
for field in ["name", "description", "icon", "icon_type", "visibility", "status", "tags"]: for field in ["name", "description", "icon", "icon_type", "visibility", "status", "tags"]:
@@ -952,7 +1026,7 @@ class AppService:
if app.type != "agent": if app.type != "agent":
raise BusinessException("只有 Agent 类型应用支持 Agent 配置", BizCode.APP_TYPE_NOT_SUPPORTED) raise BusinessException("只有 Agent 类型应用支持 Agent 配置", BizCode.APP_TYPE_NOT_SUPPORTED)
self._validate_workspace_access(app, workspace_id) self._validate_app_writable(app, workspace_id)
stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active.is_(True)).order_by( stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active.is_(True)).order_by(
AgentConfig.updated_at.desc()) AgentConfig.updated_at.desc())
@@ -1163,7 +1237,7 @@ class AppService:
if app.type != AppType.WORKFLOW: if app.type != AppType.WORKFLOW:
raise BusinessException("只有 Workflow 类型应用支持 Workflow 配置", BizCode.APP_TYPE_NOT_SUPPORTED) raise BusinessException("只有 Workflow 类型应用支持 Workflow 配置", BizCode.APP_TYPE_NOT_SUPPORTED)
self._validate_workspace_access(app, workspace_id) self._validate_app_writable(app, workspace_id)
# 获取现有配置 # 获取现有配置
repo = WorkflowConfigRepository(self.db) repo = WorkflowConfigRepository(self.db)
@@ -1654,7 +1728,8 @@ class AppService:
app_id: uuid.UUID, app_id: uuid.UUID,
target_workspace_ids: List[uuid.UUID], target_workspace_ids: List[uuid.UUID],
user_id: uuid.UUID, user_id: uuid.UUID,
workspace_id: Optional[uuid.UUID] = None workspace_id: Optional[uuid.UUID] = None,
permission: str = "readonly"
) -> list[AppShare]: ) -> list[AppShare]:
"""分享应用到其他工作空间 """分享应用到其他工作空间
@@ -1685,6 +1760,14 @@ class AppService:
app = self._get_app_or_404(app_id) app = self._get_app_or_404(app_id)
self._validate_workspace_access(app, workspace_id) self._validate_workspace_access(app, workspace_id)
# 仅允许 agent 和 workflow 类型共享multi_agent 不支持
from app.models.app_model import AppType
if app.type == AppType.MULTI_AGENT:
raise BusinessException(
"集群 Agent 不支持共享应用功能",
BizCode.INVALID_PARAMETER
)
# 2. 验证目标工作空间 # 2. 验证目标工作空间
for target_ws_id in target_workspace_ids: for target_ws_id in target_workspace_ids:
target_ws = self.db.get(Workspace, target_ws_id) target_ws = self.db.get(Workspace, target_ws_id)
@@ -1725,6 +1808,7 @@ class AppService:
source_workspace_id=app.workspace_id, source_workspace_id=app.workspace_id,
target_workspace_id=target_ws_id, target_workspace_id=target_ws_id,
shared_by=user_id, shared_by=user_id,
permission=permission,
created_at=now, created_at=now,
updated_at=now updated_at=now
) )
@@ -1848,6 +1932,119 @@ class AppService:
return shares return shares
def remove_shared_app(
self,
*,
app_id: uuid.UUID,
workspace_id: uuid.UUID
) -> None:
"""被共享者从自己的工作空间移除共享应用
只删除共享记录,不影响源应用。
Args:
app_id: 应用ID
workspace_id: 当前工作空间ID被共享的目标工作空间
Raises:
ResourceNotFoundException: 当共享记录不存在时
"""
from app.models import AppShare
logger.info(
"移除共享应用",
extra={"app_id": str(app_id), "workspace_id": str(workspace_id)}
)
stmt = select(AppShare).where(
AppShare.source_app_id == app_id,
AppShare.target_workspace_id == workspace_id
)
share = self.db.scalars(stmt).first()
if not share:
raise ResourceNotFoundException(
"共享记录",
f"app_id={app_id}, workspace_id={workspace_id}"
)
self.db.delete(share)
self.db.commit()
logger.info(
"共享应用已移除",
extra={"app_id": str(app_id), "workspace_id": str(workspace_id)}
)
def list_my_shared_out(
self,
*,
workspace_id: uuid.UUID
) -> List[AppShare]:
"""列出本工作空间主动分享出去的所有记录(我的共享)
Returns:
List[AppShare]: 分享记录列表,含源应用信息
"""
from app.models import AppShare
stmt = (
select(AppShare)
.where(AppShare.source_workspace_id == workspace_id)
.order_by(AppShare.created_at.desc())
)
return list(self.db.scalars(stmt).all())
def update_share_permission(
self,
*,
app_id: uuid.UUID,
target_workspace_id: uuid.UUID,
permission: str,
workspace_id: Optional[uuid.UUID] = None
) -> "AppShare":
"""更新共享权限readonly <-> editable
Args:
app_id: 应用ID
target_workspace_id: 目标工作空间ID
permission: 新权限值 readonly | editable
workspace_id: 当前工作空间ID用于权限验证
Returns:
AppShare: 更新后的共享记录
"""
from app.models import AppShare
if permission not in ("readonly", "editable"):
raise BusinessException("权限值无效,只允许 readonly 或 editable", BizCode.INVALID_PARAMETER)
app = self._get_app_or_404(app_id)
self._validate_workspace_access(app, workspace_id)
stmt = select(AppShare).where(
AppShare.source_app_id == app_id,
AppShare.target_workspace_id == target_workspace_id
)
share = self.db.scalars(stmt).first()
if not share:
raise ResourceNotFoundException(
"共享记录",
f"app_id={app_id}, target_workspace_id={target_workspace_id}"
)
share.permission = permission
share.updated_at = datetime.datetime.now()
self.db.commit()
self.db.refresh(share)
logger.info(
"共享权限已更新",
extra={"app_id": str(app_id), "target_workspace_id": str(target_workspace_id), "permission": permission}
)
return share
# ==================== 向后兼容的函数接口 ==================== # ==================== 向后兼容的函数接口 ====================
# 保留函数接口以兼容现有代码,但内部使用服务类 # 保留函数接口以兼容现有代码,但内部使用服务类

View File

@@ -688,7 +688,8 @@ class AgentRunService:
conversation_id=conversation_id, conversation_id=conversation_id,
app_id=agent_config.app_id, app_id=agent_config.app_id,
workspace_id=workspace_id, workspace_id=workspace_id,
user_id=user_id user_id=user_id,
sub_agent=sub_agent
) )
# 6. 加载历史消息 # 6. 加载历史消息
@@ -848,7 +849,8 @@ class AgentRunService:
conversation_id: Optional[str], conversation_id: Optional[str],
app_id: uuid.UUID, app_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
user_id: Optional[str] user_id: Optional[str],
sub_agent: bool = False
) -> str: ) -> str:
"""确保会话存在(创建或验证) """确保会话存在(创建或验证)
@@ -909,20 +911,36 @@ class AgentRunService:
conv_uuid = uuid.UUID(conversation_id) conv_uuid = uuid.UUID(conversation_id)
conversation = conversation_service.get_conversation(conv_uuid) conversation = conversation_service.get_conversation(conv_uuid)
# 验证会话属于当前工作空间 # 验证会话属于当前工作空间(或属于共享应用的源工作空间)
if conversation.workspace_id != workspace_id: # sub_agent 内部调用时跳过校验,已在上层验证过
logger.warning( if not sub_agent and conversation.workspace_id != workspace_id:
"会话不属于当前工作空间", # 检查是否是共享应用的会话(被共享者 workspace 访问源应用)
extra={ from app.models import AppShare
"conversation_id": conversation_id, from sqlalchemy import select as sa_select
"conversation_workspace_id": str(conversation.workspace_id), share = self.db.scalars(
"current_workspace_id": str(workspace_id) sa_select(AppShare).where(
} AppShare.source_app_id == app_id,
) AppShare.target_workspace_id == workspace_id
raise BusinessException( )
"会话不属于当前工作空间", ).first()
BizCode.PERMISSION_DENIED
) # 情况2sub_agent 内部调用时workspace_id 是源应用的 workspace
# 而会话是被共享者创建的,只要会话属于同一个 app 即可放行
same_app = (conversation.app_id == app_id)
if not share and not same_app:
logger.warning(
"会话不属于当前工作空间",
extra={
"conversation_id": conversation_id,
"conversation_workspace_id": str(conversation.workspace_id),
"current_workspace_id": str(workspace_id)
}
)
raise BusinessException(
"会话不属于当前工作空间",
BizCode.PERMISSION_DENIED
)
logger.debug( logger.debug(
"使用现有会话", "使用现有会话",

847
api/uv.lock generated

File diff suppressed because it is too large Load Diff