feat(app): add cross-workspace app sharing backend
This commit is contained in:
@@ -60,7 +60,12 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
||||
# are written from script.py.mako
|
||||
# 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]
|
||||
|
||||
@@ -93,6 +93,20 @@ def list_apps(
|
||||
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="获取应用详情")
|
||||
@cur_workspace_access_guard()
|
||||
def get_app(
|
||||
@@ -302,7 +316,8 @@ def share_app(
|
||||
app_id=app_id,
|
||||
target_workspace_ids=payload.target_workspace_ids,
|
||||
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]
|
||||
@@ -333,6 +348,32 @@ def unshare_app(
|
||||
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="列出应用的分享记录")
|
||||
@cur_workspace_access_guard()
|
||||
def list_app_shares(
|
||||
@@ -356,6 +397,29 @@ def list_app_shares(
|
||||
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(使用当前草稿配置)")
|
||||
@cur_workspace_access_guard()
|
||||
async def draft_run(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.db import Base
|
||||
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")
|
||||
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")
|
||||
permission = Column(String, default="readonly", nullable=False, comment="权限模式: readonly | editable")
|
||||
created_at = Column(DateTime, default=datetime.datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.now)
|
||||
|
||||
|
||||
@@ -277,7 +277,12 @@ class App(BaseModel):
|
||||
tags: List[str] = []
|
||||
current_release_id: Optional[uuid.UUID] = None
|
||||
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
|
||||
updated_at: datetime.datetime
|
||||
|
||||
@@ -422,6 +427,12 @@ class AppRelease(BaseModel):
|
||||
class AppShareCreate(BaseModel):
|
||||
"""应用分享请求"""
|
||||
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):
|
||||
@@ -433,9 +444,32 @@ class AppShare(BaseModel):
|
||||
source_workspace_id: uuid.UUID
|
||||
target_workspace_id: uuid.UUID
|
||||
shared_by: uuid.UUID
|
||||
permission: str = "readonly"
|
||||
created_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")
|
||||
def _serialize_created_at(self, dt: datetime.datetime):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
@@ -125,6 +125,49 @@ class AppService:
|
||||
)
|
||||
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:
|
||||
"""获取应用或抛出404异常
|
||||
|
||||
@@ -454,6 +497,32 @@ class AppService:
|
||||
Returns:
|
||||
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 = {
|
||||
"id": app.id,
|
||||
"workspace_id": app.workspace_id,
|
||||
@@ -468,7 +537,12 @@ class AppService:
|
||||
"tags": app.tags or [],
|
||||
"current_release_id": app.current_release_id,
|
||||
"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,
|
||||
"updated_at": app.updated_at
|
||||
}
|
||||
@@ -594,7 +668,7 @@ class AppService:
|
||||
logger.info("更新应用", extra={"app_id": str(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
|
||||
for field in ["name", "description", "icon", "icon_type", "visibility", "status", "tags"]:
|
||||
@@ -952,7 +1026,7 @@ class AppService:
|
||||
if app.type != "agent":
|
||||
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(
|
||||
AgentConfig.updated_at.desc())
|
||||
@@ -1163,7 +1237,7 @@ class AppService:
|
||||
if app.type != AppType.WORKFLOW:
|
||||
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)
|
||||
@@ -1654,7 +1728,8 @@ class AppService:
|
||||
app_id: uuid.UUID,
|
||||
target_workspace_ids: List[uuid.UUID],
|
||||
user_id: uuid.UUID,
|
||||
workspace_id: Optional[uuid.UUID] = None
|
||||
workspace_id: Optional[uuid.UUID] = None,
|
||||
permission: str = "readonly"
|
||||
) -> list[AppShare]:
|
||||
"""分享应用到其他工作空间
|
||||
|
||||
@@ -1685,6 +1760,14 @@ class AppService:
|
||||
app = self._get_app_or_404(app_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. 验证目标工作空间
|
||||
for target_ws_id in target_workspace_ids:
|
||||
target_ws = self.db.get(Workspace, target_ws_id)
|
||||
@@ -1725,6 +1808,7 @@ class AppService:
|
||||
source_workspace_id=app.workspace_id,
|
||||
target_workspace_id=target_ws_id,
|
||||
shared_by=user_id,
|
||||
permission=permission,
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
)
|
||||
@@ -1848,6 +1932,119 @@ class AppService:
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ==================== 向后兼容的函数接口 ====================
|
||||
# 保留函数接口以兼容现有代码,但内部使用服务类
|
||||
|
||||
|
||||
@@ -688,7 +688,8 @@ class AgentRunService:
|
||||
conversation_id=conversation_id,
|
||||
app_id=agent_config.app_id,
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
sub_agent=sub_agent
|
||||
)
|
||||
|
||||
# 6. 加载历史消息
|
||||
@@ -848,7 +849,8 @@ class AgentRunService:
|
||||
conversation_id: Optional[str],
|
||||
app_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: Optional[str]
|
||||
user_id: Optional[str],
|
||||
sub_agent: bool = False
|
||||
) -> str:
|
||||
"""确保会话存在(创建或验证)
|
||||
|
||||
@@ -909,8 +911,24 @@ class AgentRunService:
|
||||
conv_uuid = uuid.UUID(conversation_id)
|
||||
conversation = conversation_service.get_conversation(conv_uuid)
|
||||
|
||||
# 验证会话属于当前工作空间
|
||||
if conversation.workspace_id != workspace_id:
|
||||
# 验证会话属于当前工作空间(或属于共享应用的源工作空间)
|
||||
# sub_agent 内部调用时跳过校验,已在上层验证过
|
||||
if not sub_agent and conversation.workspace_id != workspace_id:
|
||||
# 检查是否是共享应用的会话(被共享者 workspace 访问源应用)
|
||||
from app.models import AppShare
|
||||
from sqlalchemy import select as sa_select
|
||||
share = self.db.scalars(
|
||||
sa_select(AppShare).where(
|
||||
AppShare.source_app_id == app_id,
|
||||
AppShare.target_workspace_id == workspace_id
|
||||
)
|
||||
).first()
|
||||
|
||||
# 情况2:sub_agent 内部调用时,workspace_id 是源应用的 workspace,
|
||||
# 而会话是被共享者创建的,只要会话属于同一个 app 即可放行
|
||||
same_app = (conversation.app_id == app_id)
|
||||
|
||||
if not share and not same_app:
|
||||
logger.warning(
|
||||
"会话不属于当前工作空间",
|
||||
extra={
|
||||
|
||||
847
api/uv.lock
generated
847
api/uv.lock
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user