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
This commit is contained in:
@@ -0,0 +1,870 @@
|
||||
# 好友申请功能实现计划
|
||||
|
||||
> **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"
|
||||
```
|
||||
Reference in New Issue
Block a user