# 好友申请功能实现计划 > **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** ```python # backend/src/v1/friendships/__init__.py ``` **Step 2: 创建 Pydantic schemas** ```python # 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** ```bash 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: 写入失败的测试** ```python # 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** ```python # 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** ```bash 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: 写入失败的测试** ```python # 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** ```python # 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** ```bash 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** ```python # 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** ```python # 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 到主路由** ```python # 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** ```bash 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: 写入测试** ```python # 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** ```bash cd backend && uv run ruff check src/v1/friendships/ ``` **Step 2: 运行 typecheck** ```bash 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 文档** ```markdown ## 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** ```bash git add docs/runtime/runtime-route.md git commit -m "docs: add friendship API documentation" ```