feat(workspace): restore member management routes
Deploy / Deploy Production (push) Failing after 2093h52m28s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2093h54m40s
Deploy / Deploy Staging (push) Has been cancelled

This commit is contained in:
Xiaoxia AI
2026-06-21 09:30:12 +08:00
parent 38b5b60160
commit c5321f4f12
6 changed files with 371 additions and 4 deletions
+213 -2
View File
@@ -7,18 +7,29 @@ after their persistence repositories are wired and smoke-tested.
"""
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository, get_workspace_member_repository, get_workspace_repository
from app.dependencies import (
get_user_repository,
get_workspace_invitation_repository,
get_workspace_member_repository,
get_workspace_repository,
)
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesUseCase,
)
from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase
from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
from packages.ports.workspace_repository import WorkspaceRepository
@@ -65,6 +76,54 @@ class WorkspaceDetailResponseModel(BaseModel):
user_role: str
class InviteMemberRequestModel(BaseModel):
email: EmailStr
role: str
class InviteMemberResponseModel(BaseModel):
invitation_id: str
invitee_email: str
role: str
expires_at: str
class MemberResponseModel(BaseModel):
member_id: str
user_id: str
username: str
email: str
display_name: str
role: str
invited_by: str | None
joined_at: str
class MemberListResponseModel(BaseModel):
members: list[MemberResponseModel]
class UpdateMemberRoleRequestModel(BaseModel):
role: str
class UpdateMemberRoleResponseModel(BaseModel):
user_id: str
old_role: str
new_role: str
class AcceptInvitationResponseModel(BaseModel):
workspace_id: str
workspace_name: str
role: str
class _NoopInvitationEmailService:
def send_workspace_invitation_email(self, **kwargs):
return False, "Email delivery is disabled for workspace compatibility routes"
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
async def create_workspace(
request: CreateWorkspaceRequestModel,
@@ -127,6 +186,31 @@ async def list_workspaces(
)
@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel)
async def accept_invitation(
invitation_token: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
response, error = AcceptInvitationUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id))
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation")
return AcceptInvitationResponseModel(
workspace_id=response.workspace_id,
workspace_name=response.workspace_name,
role=response.role,
)
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
async def get_workspace_detail(
workspace_id: str,
@@ -156,3 +240,130 @@ async def get_workspace_detail(
member_count=detail.member_count,
user_role=detail.user_role,
)
@router.post(
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
)
async def invite_member(
workspace_id: str,
request: InviteMemberRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = InviteMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
email_service=_NoopInvitationEmailService(),
)
response, error = use_case.execute(
InviteMemberRequest(
workspace_id=workspace_id,
inviter_user_id=authenticated_user.user.id,
invitee_email=request.email,
role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member")
return InviteMemberResponseModel(
invitation_id=response.invitation_id,
invitee_email=response.invitee_email,
role=response.role,
expires_at=response.expires_at.isoformat(),
)
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
async def list_members(
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = ListMembersUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
response, error = use_case.execute(
ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members")
return MemberListResponseModel(
members=[
MemberResponseModel(
member_id=member.member_id,
user_id=member.user_id,
username=member.username,
email=member.email,
display_name=member.display_name,
role=member.role,
invited_by=member.invited_by,
joined_at=member.joined_at.isoformat(),
)
for member in response.members
]
)
@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel)
async def update_member_role(
workspace_id: str,
user_id: str,
request: UpdateMemberRoleRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = UpdateMemberRoleUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
response, error = use_case.execute(
UpdateMemberRoleRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
new_role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role")
return UpdateMemberRoleResponseModel(
user_id=response.user_id,
old_role=response.old_role,
new_role=response.new_role,
)
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
success, error = RemoveMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
).execute(
RemoveMemberRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
)
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
+8
View File
@@ -23,9 +23,11 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
)
from packages.adapters.sqlalchemy_impl.session import build_session_factory
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
from packages.ports.workspace_repository import WorkspaceRepository
@@ -98,3 +100,9 @@ def get_workspace_member_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceMemberRepository:
return SQLAlchemyWorkspaceMemberRepository(session)
def get_workspace_invitation_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceInvitationRepository:
return SQLAlchemyWorkspaceInvitationRepository(session)
@@ -0,0 +1,79 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import WorkspaceInvitationModel
from packages.domain.entities import WorkspaceInvitation
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
class SQLAlchemyWorkspaceInvitationRepository(WorkspaceInvitationRepository):
def __init__(self, session: Session):
self.session = session
def save(self, invitation: WorkspaceInvitation) -> None:
model = self.session.get(WorkspaceInvitationModel, invitation.id)
if model is None:
model = WorkspaceInvitationModel(id=invitation.id)
self.session.add(model)
model.workspace_id = invitation.workspace_id
model.inviter_user_id = invitation.inviter_user_id
model.invitee_email = invitation.invitee_email.strip().lower()
model.role = str(invitation.role)
model.invitation_token = invitation.invitation_token
model.status = str(invitation.status)
model.expires_at = invitation.expires_at
model.accepted_at = invitation.accepted_at
model.created_at = invitation.created_at
self.session.commit()
self.session.refresh(model)
def find_by_id(self, invitation_id: str) -> WorkspaceInvitation | None:
return self._to_entity(self.session.get(WorkspaceInvitationModel, invitation_id))
def find_by_token(self, token: str) -> WorkspaceInvitation | None:
model = (
self.session.query(WorkspaceInvitationModel)
.filter(WorkspaceInvitationModel.invitation_token == token)
.first()
)
return self._to_entity(model)
def find_pending_by_workspace_and_email(self, workspace_id: str, email: str) -> WorkspaceInvitation | None:
model = (
self.session.query(WorkspaceInvitationModel)
.filter(
WorkspaceInvitationModel.workspace_id == workspace_id,
WorkspaceInvitationModel.invitee_email == email.strip().lower(),
WorkspaceInvitationModel.status == "pending",
)
.first()
)
return self._to_entity(model)
def delete(self, invitation_id: str) -> bool:
model = self.session.get(WorkspaceInvitationModel, invitation_id)
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
@staticmethod
def _to_entity(model: WorkspaceInvitationModel | None) -> WorkspaceInvitation | None:
if model is None:
return None
return WorkspaceInvitation(
id=model.id,
workspace_id=model.workspace_id,
inviter_user_id=model.inviter_user_id,
invitee_email=model.invitee_email,
role=model.role,
invitation_token=model.invitation_token,
status=model.status,
expires_at=model.expires_at,
accepted_at=model.accepted_at,
created_at=model.created_at,
)
@@ -74,7 +74,10 @@ class AcceptInvitationUseCase:
return None, f"Invitation has already been {invitation.status}"
# 4. 检查是否过期
if invitation.expires_at and datetime.now(timezone.utc) > invitation.expires_at:
expires_at = invitation.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at and datetime.now(timezone.utc) > expires_at:
# 更新状态为过期
invitation.status = InvitationStatus.EXPIRED
self.workspace_invitation_repository.save(invitation)
@@ -137,6 +137,30 @@ class TestAcceptInvitationUseCase:
assert valid_invitation.status == InvitationStatus.ACCEPTED
assert valid_invitation.accepted_at is not None
def test_accept_invitation_success_with_naive_database_datetime(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
mock_invitation_repo,
mock_user_repo,
test_workspace,
test_user,
valid_invitation,
):
"""测试数据库返回 naive datetime 时仍可接受邀请"""
valid_invitation.expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).replace(tzinfo=None)
mock_invitation_repo.find_by_token.return_value = valid_invitation
mock_user_repo.find_by_id.return_value = test_user
mock_workspace_repo.find_by_id.return_value = test_workspace
mock_member_repo.find_by_workspace_and_user.return_value = None
response, error = use_case.execute(AcceptInvitationRequest(invitation_token="valid-token", user_id="user-123"))
assert error is None
assert response is not None
assert response.workspace_id == "workspace-123"
def test_accept_invitation_invalid_token(self, use_case, mock_invitation_repo):
"""测试无效令牌"""
mock_invitation_repo.find_by_token.return_value = None
@@ -3,19 +3,23 @@ from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.models import Base
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
from packages.application.workspace.list_workspaces_use_case import ListWorkspacesRequest, ListWorkspacesUseCase
from packages.domain.auth import password_hasher
from packages.domain.entities import User
def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories():
def test_workspace_create_list_invite_and_members_with_sqlalchemy_repositories():
session = _sqlite_session()
user_repository = SQLAlchemyUserRepository(session)
workspace_repository = SQLAlchemyWorkspaceRepository(session)
workspace_member_repository = SQLAlchemyWorkspaceMemberRepository(session)
workspace_invitation_repository = SQLAlchemyWorkspaceInvitationRepository(session)
user_repository.save(
User(
id="user-1",
@@ -48,6 +52,44 @@ def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories():
assert list_response.workspaces[0].user_role == "owner"
assert list_response.workspaces[0].member_count == 1
invite_response, invite_error = InviteMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
email_service=_NoopEmailService(),
).execute(
InviteMemberRequest(
workspace_id=create_response.workspace_id,
inviter_user_id="user-1",
invitee_email="Invitee@Example.com",
role="member",
)
)
assert invite_error is None
assert invite_response is not None
assert invite_response.invitee_email == "invitee@example.com"
assert workspace_invitation_repository.find_pending_by_workspace_and_email(
create_response.workspace_id, "invitee@example.com"
)
members_response, members_error = ListMembersUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
).execute(ListMembersRequest(workspace_id=create_response.workspace_id, requester_user_id="user-1"))
assert members_error is None
assert members_response is not None
assert len(members_response.members) == 1
assert members_response.members[0].role == "owner"
class _NoopEmailService:
def send_workspace_invitation_email(self, **kwargs):
return False, "disabled"
def _sqlite_session():
engine = create_engine("sqlite:///:memory:")