Files
social-app/docs/plans/2026-02-28-friendship-implementation-plan.md
T
qzl c3192a2431 feat(chat): add ChatBubble widget and mock data for home screen
- Add ChatBubble reusable widget for chat messages
- Add HomeMockData for chat list mock data
- Add HomeScreen widget tests
- Add AG-UI chat design and implementation plan docs
- Add friendship design docs
- Ignore backend/logs directory
2026-02-28 14:47:33 +08:00

26 KiB

好友申请功能实现计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 实现好友申请、待办消息、添加/删除好友等系列功能的后端API

Architecture: 使用 repository/service/router 模式,复用已有的 Friendship 和 InboxMessage 模型,通过 inbox_messages 表存储好友请求通知

Tech Stack: FastAPI, SQLAlchemy, Pydantic


Task 1: 创建 friendships 模块目录结构和基础文件

Files:

  • Create: backend/src/v1/friendships/__init__.py
  • Create: backend/src/v1/friendships/schemas.py
  • Create: backend/src/v1/friendships/repository.py
  • Create: backend/src/v1/friendships/service.py
  • Create: backend/src/v1/friendships/dependencies.py
  • Create: backend/src/v1/friendships/router.py

Step 1: 创建目录和基础 schema

# backend/src/v1/friendships/__init__.py

Step 2: 创建 Pydantic schemas

# backend/src/v1/friendships/schemas.py
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel, Field


class UserBasicInfo(BaseModel):
    id: str
    username: str
    avatar_url: Optional[str] = None


class FriendRequestCreate(BaseModel):
    target_user_id: UUID
    content: Optional[str] = Field(None, max_length=200)


class FriendRequestResponse(BaseModel):
    id: UUID
    sender: UserBasicInfo
    recipient: UserBasicInfo
    content: Optional[str]
    status: str
    created_at: datetime


class FriendResponse(BaseModel):
    id: UUID
    friend: UserBasicInfo
    status: str
    created_at: datetime
    accepted_at: Optional[datetime]


class FriendRequestAction(BaseModel):
    # For accept/decline - no body needed but kept for extensibility
    pass

Step 3: Commit

git add backend/src/v1/friendships/
git commit -m "feat(friendships): create module structure and schemas"

Task 2: 实现 FriendshipRepository

Files:

  • Modify: backend/src/v1/friendships/repository.py

Step 1: 写入失败的测试

# backend/tests/unit/v1/friendships/test_friendship_repository.py
import pytest
from uuid import uuid4
from v1.friendships.repository import FriendshipRepository


@pytest.fixture
def mock_session():
    # Create mock async session
    pass


@pytest.mark.asyncio
async def test_create_friendship_request(mock_session):
    repository = FriendshipRepository(mock_session)
    # Test creating friendship request
    pass


@pytest.mark.asyncio
async def test_get_pending_request_between_users(mock_session):
    repository = FriendshipRepository(mock_session)
    # Test checking existing requests
    pass

Step 2: 运行测试确认失败

Step 3: 实现 repository

# backend/src/v1/friendships/repository.py
from __future__ import annotations

from typing import Optional
from uuid import UUID

from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession

from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus


class FriendshipRepository:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def create_request(
        self,
        user_low_id: UUID,
        user_high_id: UUID,
        initiator_id: UUID,
        recipient_id: UUID,
        content: Optional[str] = None,
    ) -> tuple[Friendship, InboxMessage]:
        friendship = Friendship(
            user_low_id=user_low_id,
            user_high_id=user_high_id,
            initiator_id=initiator_id,
            status=FriendshipStatus.PENDING,
        )
        self._session.add(friendship)
        await self._session.flush()

        inbox_message = InboxMessage(
            recipient_id=recipient_id,
            sender_id=initiator_id,
            message_type=InboxMessageType.FRIEND_REQUEST,
            friendship_id=friendship.id,
            content=content,
            status=InboxMessageStatus.PENDING,
        )
        self._session.add(inbox_message)
        return friendship, inbox_message

    async def get_friendship_between_users(
        self, user_a_id: UUID, user_b_id: UUID
    ) -> Optional[Friendship]:
        low_id = min(user_a_id, user_b_id)
        high_id = max(user_a_id, user_b_id)
        stmt = select(Friendship).where(
            and_(
                Friendship.user_low_id == low_id,
                Friendship.user_high_id == high_id,
            )
        )
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()

    async def get_pending_inbox_for_recipient(
        self, friendship_id: UUID, recipient_id: UUID
    ) -> Optional[InboxMessage]:
        stmt = select(InboxMessage).where(
            and_(
                InboxMessage.friendship_id == friendship_id,
                InboxMessage.recipient_id == recipient_id,
                InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
            )
        )
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()

    async def get_friendship_by_id(self, friendship_id: UUID) -> Optional[Friendship]:
        stmt = select(Friendship).where(Friendship.id == friendship_id)
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()

    async def get_inbox_messages_for_user(
        self, user_id: UUID, status: Optional[InboxMessageStatus] = None
    ) -> list[InboxMessage]:
        stmt = select(InboxMessage).where(
            and_(
                InboxMessage.recipient_id == user_id,
                InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
            )
        )
        if status:
            stmt = stmt.where(InboxMessage.status == status)
        stmt = stmt.order_by(InboxMessage.created_at.desc())
        result = await self._session.execute(stmt)
        return list(result.scalars().all())

    async def get_outgoing_requests(
        self, user_id: UUID, status: Optional[FriendshipStatus] = None
    ) -> list[Friendship]:
        stmt = select(Friendship).where(Friendship.initiator_id == user_id)
        if status:
            stmt = stmt.where(Friendship.status == status)
        else:
            stmt = stmt.where(Friendship.status == FriendshipStatus.PENDING)
        stmt = stmt.order_by(Friendship.created_at.desc())
        result = await self._session.execute(stmt)
        return list(result.scalars().all())

    async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
        stmt = select(Friendship).where(
            or_(
                Friendship.user_low_id == user_id,
                Friendship.user_high_id == user_id,
            ),
            Friendship.status == FriendshipStatus.ACCEPTED,
        ).order_by(Friendship.updated_at.desc())
        result = await self._session.execute(stmt)
        return list(result.scalars().all())

Step 4: 运行测试确认通过

Step 5: Commit

git add backend/src/v1/friendships/repository.py backend/tests/unit/v1/friendships/
git commit -m "feat(friendships): implement repository"

Task 3: 实现 FriendshipService

Files:

  • Modify: backend/src/v1/friendships/service.py

Step 1: 写入失败的测试

# backend/tests/unit/v1/friendships/test_friendship_service.py
import pytest
from uuid import uuid4
from v1.friendships.service import FriendshipService


@pytest.fixture
def mock_repository():
    pass


@pytest.mark.asyncio
async def test_send_friend_request_success(mock_repository):
    service = FriendshipService(mock_repository, current_user)
    # Test successful friend request
    pass


@pytest.mark.asyncio
async def test_send_friend_request_to_self_fails():
    # Test that sending to self returns 400
    pass


@pytest.mark.asyncio
async def test_send_friend_request_when_already_friends():
    # Test that sending to existing friend returns 409
    pass

Step 2: 运行测试确认失败

Step 3: 实现 service

# backend/src/v1/friendships/service.py
from __future__ import annotations

from datetime import datetime
from typing import Optional
from uuid import UUID

from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessageStatus
from models.profile import Profile
from v1.friendships.repository import FriendshipRepository
from v1.friendships.schemas import (
    FriendRequestCreate,
    FriendRequestResponse,
    FriendResponse,
    UserBasicInfo,
)

logger = get_logger("v1.friendships.service")


class FriendshipService(BaseService):
    def __init__(
        self,
        repository: FriendshipRepository,
        session: AsyncSession,
        current_user: CurrentUser,
    ) -> None:
        super().__init__(current_user=current_user)
        self._repository = repository
        self._session = session

    async def send_request(
        self, payload: FriendRequestCreate
    ) -> FriendRequestResponse:
        current_user_id = self.require_user_id()
        target_user_id = payload.target_user_id

        if current_user_id == target_user_id:
            raise HTTPException(
                status_code=400,
                detail="Cannot send friend request to yourself"
            )

        # Check existing relationship
        existing = await self._repository.get_friendship_between_users(
            current_user_id, target_user_id
        )

        if existing:
            if existing.status == FriendshipStatus.ACCEPTED:
                raise HTTPException(status_code=409, detail="Already friends")
            if existing.status == FriendshipStatus.PENDING:
                raise HTTPException(status_code=409, detail="Friend request already exists")
            if existing.status == FriendshipStatus.BLOCKED:
                raise HTTPException(status_code=403, detail="Blocked by user")

        user_low_id = min(current_user_id, target_user_id)
        user_high_id = max(current_user_id, target_user_id)

        friendship, inbox = await self._repository.create_request(
            user_low_id=user_low_id,
            user_high_id=user_high_id,
            initiator_id=current_user_id,
            recipient_id=target_user_id,
            content=payload.content,
        )
        await self._session.commit()

        sender_info = await self._get_profile_info(current_user_id)
        recipient_info = await self._get_profile_info(target_user_id)

        return FriendRequestResponse(
            id=friendship.id,
            sender=sender_info,
            recipient=recipient_info,
            content=payload.content,
            status=friendship.status.value,
            created_at=friendship.created_at,
        )

    async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
        current_user_id = self.require_user_id()

        friendship = await self._repository.get_friendship_by_id(friendship_id)
        if not friendship:
            raise HTTPException(status_code=404, detail="Friend request not found")

        # Determine recipient - must be the current user
        recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
        if recipient_id != current_user_id:
            raise HTTPException(status_code=403, detail="Not authorized")

        inbox = await self._repository.get_pending_inbox_for_recipient(
            friendship_id, current_user_id
        )

        friendship.status = FriendshipStatus.ACCEPTED
        friendship.accepted_at = datetime.utcnow()
        
        if inbox:
            inbox.status = InboxMessageStatus.ACCEPTED
        
        await self._session.commit()

        initiator_info = await self._get_profile_info(friendship.initiator_id)
        recipient_info = await self._get_profile_info(current_user_id)

        return FriendRequestResponse(
            id=friendship.id,
            sender=initiator_info,
            recipient=recipient_info,
            content=inbox.content if inbox else None,
            status=friendship.status.value,
            created_at=friendship.created_at,
        )

    async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse:
        current_user_id = self.require_user_id()

        friendship = await self._repository.get_friendship_by_id(friendship_id)
        if not friendship:
            raise HTTPException(status_code=404, detail="Friend request not found")

        recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
        if recipient_id != current_user_id:
            raise HTTPException(status_code=403, detail="Not authorized")

        inbox = await self._repository.get_pending_inbox_for_recipient(
            friendship_id, current_user_id
        )

        friendship.status = FriendshipStatus.DECLINED
        
        if inbox:
            inbox.status = InboxMessageStatus.REJECTED
        
        await self._session.commit()

        initiator_info = await self._get_profile_info(friendship.initiator_id)
        recipient_info = await self._get_profile_info(current_user_id)

        return FriendRequestResponse(
            id=friendship.id,
            sender=initiator_info,
            recipient=recipient_info,
            content=inbox.content if inbox else None,
            status=friendship.status.value,
            created_at=friendship.created_at,
        )

    async def cancel_request(self, friendship_id: UUID) -> None:
        current_user_id = self.require_user_id()

        friendship = await self._repository.get_friendship_by_id(friendship_id)
        if not friendship:
            raise HTTPException(status_code=404, detail="Friend request not found")

        if friendship.initiator_id != current_user_id:
            raise HTTPException(status_code=403, detail="Not authorized")

        if friendship.status != FriendshipStatus.PENDING:
            raise HTTPException(status_code=400, detail="Can only cancel pending requests")

        inbox = await self._repository.get_pending_inbox_for_recipient(
            friendship_id, friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
        )

        friendship.status = FriendshipStatus.CANCELED
        
        if inbox:
            inbox.status = InboxMessageStatus.DISMISSED
        
        await self._session.commit()

    async def get_inbox(self) -> list[FriendRequestResponse]:
        current_user_id = self.require_user_id()
        inbox_messages = await self._repository.get_pending_inbox_for_user(
            current_user_id, InboxMessageStatus.PENDING
        )

        results = []
        for msg in inbox_messages:
            friendship = await self._repository.get_friendship_by_id(msg.friendship_id)
            if not friendship:
                continue
            
            sender_info = await self._get_profile_info(msg.sender_id)
            recipient_info = await self._get_profile_info(current_user_id)
            
            results.append(FriendRequestResponse(
                id=friendship.id,
                sender=sender_info,
                recipient=recipient_info,
                content=msg.content,
                status=msg.status.value,
                created_at=msg.created_at,
            ))

        return results

    async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
        current_user_id = self.require_user_id()
        friendships = await self._repository.get_outgoing_requests(current_user_id)

        results = []
        for friendship in friendships:
            sender_info = await self._get_profile_info(current_user_id)
            recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
            recipient_info = await self._get_profile_info(recipient_id)
            
            inbox = await self._repository.get_pending_inbox_for_recipient(
                friendship.id, recipient_id
            )

            results.append(FriendRequestResponse(
                id=friendship.id,
                sender=sender_info,
                recipient=recipient_info,
                content=inbox.content if inbox else None,
                status=friendship.status.value,
                created_at=friendship.created_at,
            ))

        return results

    async def get_friends_list(self) -> list[FriendResponse]:
        current_user_id = self.require_user_id()
        friendships = await self._repository.get_friends_list(current_user_id)

        results = []
        for friendship in friendships:
            friend_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
            friend_info = await self._get_profile_info(friend_id)

            results.append(FriendResponse(
                id=friendship.id,
                friend=friend_info,
                status=friendship.status.value,
                created_at=friendship.created_at,
                accepted_at=friendship.accepted_at,
            ))

        return results

    async def remove_friend(self, friendship_id: UUID) -> None:
        current_user_id = self.require_user_id()

        friendship = await self._repository.get_friendship_by_id(friendship_id)
        if not friendship:
            raise HTTPException(status_code=404, detail="Friendship not found")

        if friendship.status != FriendshipStatus.ACCEPTED:
            raise HTTPException(status_code=400, detail="Can only remove accepted friends")

        # Verify user is part of this friendship
        if friendship.user_low_id != current_user_id and friendship.user_high_id != current_user_id:
            raise HTTPException(status_code=403, detail="Not authorized")

        # Soft delete - mark as canceled
        friendship.status = FriendshipStatus.CANCELED
        await self._session.commit()

    async def _get_profile_info(self, user_id: UUID) -> UserBasicInfo:
        from sqlalchemy import select
        from models.profile import Profile
        
        stmt = select(Profile).where(Profile.id == user_id)
        result = await self._session.execute(stmt)
        profile = result.scalar_one_or_none()
        
        if not profile:
            return UserBasicInfo(id=str(user_id), username="Unknown")
        
        return UserBasicInfo(
            id=str(profile.id),
            username=profile.username,
            avatar_url=profile.avatar_url,
        )

Step 4: 运行测试确认通过

Step 5: Commit

git add backend/src/v1/friendships/service.py
git commit -m "feat(friendships): implement service layer"

Task 4: 实现 Dependencies 和 Router

Files:

  • Modify: backend/src/v1/friendships/dependencies.py
  • Modify: backend/src/v1/friendships/router.py

Step 1: 实现 dependencies

# backend/src/v1/friendships/dependencies.py
from __future__ import annotations

from typing import Annotated
from uuid import UUID

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from core.auth.models import CurrentUser
from core.db import get_db
from v1.friendships.repository import FriendshipRepository
from v1.friendships.service import FriendshipService
from v1.users.dependencies import get_current_user


async def get_friendship_repository(
    session: Annotated[AsyncSession, Depends(get_db)]
) -> FriendshipRepository:
    return FriendshipRepository(session)


async def get_friendship_service(
    repository: Annotated[FriendshipRepository, Depends(get_friendship_repository)],
    session: Annotated[AsyncSession, Depends(get_db)],
    user: Annotated[CurrentUser, Depends(get_current_user)],
) -> FriendshipService:
    return FriendshipService(
        repository=repository,
        session=session,
        current_user=user,
    )

Step 2: 实现 router

# backend/src/v1/friendships/router.py
from __future__ import annotations

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, Path, HTTPException

from v1.friendships.dependencies import get_friendship_service
from v1.friendships.schemas import (
    FriendRequestCreate,
    FriendRequestResponse,
    FriendResponse,
)
from v1.friendships.service import FriendshipService


router = APIRouter(prefix="/friends", tags=["friends"])


@router.post("/requests", response_model=FriendRequestResponse, status_code=201)
async def send_friend_request(
    payload: FriendRequestCreate,
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
    return await service.send_request(payload)


@router.get("/requests/inbox", response_model=list[FriendRequestResponse])
async def get_inbox(
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
    return await service.get_inbox()


@router.get("/requests/outgoing", response_model=list[FriendRequestResponse])
async def get_outgoing_requests(
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
    return await service.get_outgoing_requests()


@router.post("/requests/{friendship_id}/accept", response_model=FriendRequestResponse)
async def accept_friend_request(
    friendship_id: Annotated[UUID, Path()],
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
    return await service.accept_request(friendship_id)


@router.post("/requests/{friendship_id}/decline", response_model=FriendRequestResponse)
async def decline_friend_request(
    friendship_id: Annotated[UUID, Path()],
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
    return await service.decline_request(friendship_id)


@router.delete("/requests/{friendship_id}", status_code=204)
async def cancel_friend_request(
    friendship_id: Annotated[UUID, Path()],
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
    await service.cancel_request(friendship_id)


@router.get("", response_model=list[FriendResponse])
async def get_friends_list(
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendResponse]:
    return await service.get_friends_list()


@router.delete("/{friendship_id}", status_code=204)
async def remove_friend(
    friendship_id: Annotated[UUID, Path()],
    service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
    await service.remove_friend(friendship_id)

Step 3: 注册 router 到主路由

# backend/src/v1/router.py
from fastapi import APIRouter
from v1.auth.router import router as auth_router
from v1.users.router import router as users_router
from v1.profile.router import router as profile_router
from v1.friendships.router import router as friendships_router

router = APIRouter()

router.include_router(auth_router)
router.include_router(users_router)
router.include_router(profile_router)
router.include_router(friendships_router)

Step 4: Commit

git add backend/src/v1/friendships/dependencies.py backend/src/v1/friendships/router.py backend/src/v1/router.py
git commit -m "feat(friendships): implement router and dependencies"

Task 5: 集成测试

Files:

  • Create: backend/tests/integration/test_friendship_routes.py

Step 1: 写入测试

# backend/tests/integration/test_friendship_routes.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.pool import StaticPool

from main import app  # FastAPI app
from core.db.base import Base
from core.db import get_db


@pytest.fixture
async def async_client():
    # Setup test database
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    async def override_get_db():
        async with AsyncSession(engine) as session:
            yield session
    
    app.dependency_overrides[get_db] = override_get_db
    
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client
    
    app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_send_friend_request_requires_auth(async_client):
    response = await async_client.post(
        "/api/v1/friends/requests",
        json={"target_user_id": "..."}
    )
    assert response.status_code == 401


# More tests...

Step 2: 运行测试

Step 3: Commit


Task 6: 运行 Lint 和 Typecheck

Step 1: 运行 ruff

cd backend && uv run ruff check src/v1/friendships/

Step 2: 运行 typecheck

cd backend && uv run basedpyright src/v1/friendships/

Step 3: Commit (if any fixes needed)


Task 7: 更新文档

Files:

  • Modify: docs/runtime/runtime-route.md

Step 1: 添加 API 文档

## Friends

### Send Friend Request
- **POST** `/api/v1/friends/requests`
- **Auth:** Required
- **Body:** `{ "target_user_id": "uuid", "content": "string?" }`
- **Response:** `FriendRequestResponse`

### Get Inbox
- **GET** `/api/v1/friends/requests/inbox`
- **Auth:** Required
- **Response:** `FriendRequestResponse[]`

### Accept Request
- **POST** `/api/v1/friends/requests/{id}/accept`
- **Auth:** Required
- **Response:** `FriendRequestResponse`

### Decline Request
- **POST** `/api/v1/friends/requests/{id}/decline`
- **Auth:** Required
- **Response:** `FriendRequestResponse`

### Get Friends List
- **GET** `/api/v1/friends`
- **Auth:** Required
- **Response:** `FriendResponse[]`

### Remove Friend
- **DELETE** `/api/v1/friends/{id}`
- **Auth:** Required

Step 2: Commit

git add docs/runtime/runtime-route.md
git commit -m "docs: add friendship API documentation"