# Phase F.2：API 增强与安全

日期：2026-04-04
状态：待开始
依赖：F.1（待完成）
前置：routers/forum.py

---

## 1. 本阶段目的

增强 Jarvis Forum API 的功能和安全：

- 引入文件锁机制（并发控制）
- 强化输入验证
- 扩展 API 端点
- 实现缓存机制
- 添加限流保护

---

## 2. Forum Service 架构

### 2.1 目录结构

```
backend/app/services/
└── forum_service.py  # 新增
```

### 2.2 核心服务类

```python
class ForumLockManager:
    """文件锁管理器 - 防止并发写入冲突"""
    
    def __init__(self, max_concurrent: int = 5, timeout: int = 10):
        self.locks: Dict[str, LockInfo] = {}
        self.max_concurrent = max_concurrent
        self.timeout = timeout
    
    async def acquire_lock(self, resource_id: str) -> bool:
        """获取锁"""
    
    def release_lock(self, resource_id: str) -> None:
        """释放锁"""
    
    def cleanup_stale_locks(self) -> None:
        """清理过期锁"""


class ForumService:
    """论坛服务主类"""
    
    def __init__(
        self,
        db: AsyncSession,
        lock_manager: ForumLockManager,
    ):
        self.db = db
        self.lock = lock_manager
    
    # === 板块操作 ===
    async def create_board(self, data: ForumBoardCreate) -> ForumBoard:
        """创建板块"""
    
    async def list_boards(self) -> List[ForumBoard]:
        """列出板块"""
    
    # === 帖子操作 ===
    async def create_post(
        self,
        user_id: str,
        data: ForumPostCreate,
    ) -> ForumPost:
        """创建帖子（带锁）"""
        async with self.lock.acquire_lock(f"post:{user_id}"):
            # 创建帖子逻辑
    
    async def get_posts(
        self,
        board_id: Optional[str] = None,
        category: Optional[str] = None,
        tags: Optional[List[str]] = None,
        page: int = 1,
        page_size: int = 20,
    ) -> PaginatedResult[ForumPostOut]:
        """分页获取帖子"""
    
    async def get_post(self, post_id: str, user_id: Optional[str] = None) -> ForumPostOut:
        """获取帖子详情（增加浏览量）"""
    
    async def update_post(
        self,
        post_id: str,
        user_id: str,
        data: ForumPostUpdate,
    ) -> ForumPost:
        """更新帖子"""
    
    async def delete_post(self, post_id: str, user_id: str) -> None:
        """删除帖子（软删除）"""
    
    async def pin_post(self, post_id: str, is_pinned: bool) -> None:
        """置顶/取消置顶"""
    
    async def lock_post(self, post_id: str, is_locked: bool) -> None:
        """锁定/解锁帖子"""
    
    # === 回复操作 ===
    async def create_reply(
        self,
        post_id: str,
        user_id: Optional[str],
        data: ForumReplyCreate,
    ) -> ForumReply:
        """创建回复（带锁）"""
    
    async def get_replies(
        self,
        post_id: str,
        page: int = 1,
        page_size: int = 50,
    ) -> PaginatedResult[ForumReplyOut]:
        """获取回复列表"""
    
    async def update_reply(
        self,
        reply_id: str,
        user_id: str,
        content: str,
    ) -> ForumReply:
        """更新回复"""
    
    async def delete_reply(self, reply_id: str, user_id: str) -> None:
        """删除回复"""
    
    # === 点赞操作 ===
    async def toggle_like(
        self,
        user_id: str,
        post_id: Optional[str] = None,
        reply_id: Optional[str] = None,
    ) -> bool:
        """切换点赞状态"""
    
    # === 标签操作 ===
    async def create_tag(self, name: str, color: str = "#666666") -> ForumTag:
        """创建标签"""
    
    async def search_tags(self, query: str) -> List[ForumTag]:
        """搜索标签"""
    
    async def add_tags_to_post(
        self,
        post_id: str,
        user_id: str,
        tags: List[str],
    ) -> None:
        """为帖子添加标签"""
```

---

## 3. 安全机制

### 3.1 输入验证

```python
# Forum 安全配置
FORUM_CONFIG = {
    "MAX_CONTENT_LENGTH": 50000,      # 内容最大 50KB
    "MAX_TITLE_LENGTH": 200,           # 标题最大 200 字符
    "MAX_TAGS_PER_POST": 10,           # 每帖最多标签
    "MAX_REPLIES_PER_POST": 500,        # 每帖最多回复
    "MAX_POSTS_PER_USER_PER_DAY": 50,   # 每人每天最多发帖
    "MAX_REPLIES_PER_USER_PER_DAY": 200, # 每人每天最多回复
}


def sanitize_input(text: str, max_length: int) -> str:
    """清理用户输入"""
    if not text or not isinstance(text, str):
        return ""
    # 移除控制字符
    text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
    # 限制长度
    return text[:max_length]


def validate_post_data(data: ForumPostCreate) -> ForumPostCreate:
    """验证帖子数据"""
    # 标题验证
    data.title = sanitize_input(data.title, FORUM_CONFIG["MAX_TITLE_LENGTH"])
    if len(data.title.strip()) < 3:
        raise ValueError("标题至少 3 个字符")
    
    # 内容验证
    data.content = sanitize_input(data.content, FORUM_CONFIG["MAX_CONTENT_LENGTH"])
    if len(data.content.strip()) < 10:
        raise ValueError("内容至少 10 个字符")
    
    # 标签验证
    if len(data.tags) > FORUM_CONFIG["MAX_TAGS_PER_POST"]:
        raise ValueError(f"最多 {FORUM_CONFIG['MAX_TAGS_PER_POST']} 个标签")
    data.tags = [sanitize_input(t, 50) for t in data.tags]
    
    return data
```

### 3.2 并发控制

```python
class RateLimiter:
    """简单的限流器"""
    
    def __init__(self):
        self.user_requests: Dict[str, List[datetime]] = defaultdict(list)
    
    def check_rate_limit(
        self,
        user_id: str,
        action: str,
        max_requests: int,
        window_seconds: int = 86400,
    ) -> bool:
        """检查是否超过限流"""
        now = datetime.utcnow()
        cutoff = now - timedelta(seconds=window_seconds)
        
        # 清理过期记录
        self.user_requests[user_id] = [
            t for t in self.user_requests[user_id] if t > cutoff
        ]
        
        if len(self.user_requests[user_id]) >= max_requests:
            return False
        
        self.user_requests[user_id].append(now)
        return True
```

### 3.3 缓存策略

```python
from functools import lru_cache
from typing import Optional
import json


class ForumCache:
    """论坛缓存"""
    
    def __init__(self):
        self.cache: Dict[str, CacheEntry] = {}
        self.max_size = 1000
        self.default_ttl = 300  # 5 分钟
    
    async def get(self, key: str) -> Optional[Any]:
        """获取缓存"""
        if key in self.cache:
            entry = self.cache[key]
            if entry.is_expired():
                del self.cache[key]
            else:
                return entry.value
        return None
    
    async def set(self, key: str, value: Any, ttl: int = None) -> None:
        """设置缓存"""
        if len(self.cache) >= self.max_size:
            # LRU 清理
            oldest = min(self.cache.items(), key=lambda x: x[1].created_at)
            del self.cache[oldest[0]]
        
        self.cache[key] = CacheEntry(
            value=value,
            ttl=ttl or self.default_ttl,
        )
    
    async def invalidate(self, pattern: str) -> None:
        """清除匹配模式的缓存"""
        for key in list(self.cache.keys()):
            if pattern.format(key):
                del self.cache[key]
```

---

## 4. API 端点扩展

### 4.1 板块 API

```python
@router.get("/boards", response_model=list[ForumBoardOut])
async def list_boards(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """列出所有板块"""
    service = ForumService(db, lock_manager)
    return await service.list_boards()


@router.post("/boards", response_model=ForumBoardOut, status_code=201)
async def create_board(
    data: ForumBoardCreate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """创建板块（仅管理员）"""
    if current_user.role != "admin":
        raise HTTPException(status_code=403, detail="需要管理员权限")
    service = ForumService(db, lock_manager)
    return await service.create_board(data)
```

### 4.2 帖子 API 扩展

```python
@router.get("/posts", response_model=PaginatedResponse[ForumPostOut])
async def list_posts(
    board_id: Optional[str] = None,
    category: Optional[str] = None,
    tags: Optional[str] = None,  # comma-separated
    page: int = 1,
    page_size: int = 20,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """分页获取帖子"""
    service = ForumService(db, lock_manager)
    tag_list = tags.split(",") if tags else None
    return await service.get_posts(
        board_id=board_id,
        category=category,
        tags=tag_list,
        page=page,
        page_size=page_size,
    )


@router.post("/posts", response_model=ForumPostOut, status_code=201)
async def create_post(
    data: ForumPostCreate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """创建帖子"""
    # 限流检查
    if not rate_limiter.check_rate_limit(
        current_user.id, "post", FORUM_CONFIG["MAX_POSTS_PER_USER_PER_DAY"]
    ):
        raise HTTPException(status_code=429, detail="今日发帖次数已达上限")
    
    # 验证数据
    data = validate_post_data(data)
    
    service = ForumService(db, lock_manager)
    return await service.create_post(current_user.id, data)


@router.patch("/posts/{post_id}/pin")
async def pin_post(
    post_id: str,
    is_pinned: bool,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """置顶/取消置顶（仅版主+）"""
    service = ForumService(db, lock_manager)
    await service.pin_post(post_id, is_pinned)
    return {"success": True}


@router.patch("/posts/{post_id}/lock")
async def lock_post(
    post_id: str,
    is_locked: bool,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """锁定/解锁帖子"""
    service = ForumService(db, lock_manager)
    await service.lock_post(post_id, is_locked)
    return {"success": True}
```

### 4.3 标签 API

```python
@router.get("/tags", response_model=list[ForumTagOut])
async def list_tags(
    query: Optional[str] = None,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """搜索/列出标签"""
    service = ForumService(db, lock_manager)
    if query:
        return await service.search_tags(query)
    return await service.list_tags()


@router.post("/posts/{post_id}/tags")
async def add_tags(
    post_id: str,
    tags: List[str],
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """为帖子添加标签"""
    service = ForumService(db, lock_manager)
    await service.add_tags_to_post(post_id, current_user.id, tags)
    return {"success": True}
```

### 4.4 点赞 API

```python
@router.post("/like")
async def toggle_like(
    post_id: Optional[str] = None,
    reply_id: Optional[str] = None,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """切换点赞状态"""
    if not post_id and not reply_id:
        raise HTTPException(status_code=400, detail="需要指定 post_id 或 reply_id")
    
    service = ForumService(db, lock_manager)
    liked = await service.toggle_like(
        user_id=current_user.id,
        post_id=post_id,
        reply_id=reply_id,
    )
    return {"liked": liked}
```

---

## 5. 实现步骤

| 步骤 | 任务 | 优先级 |
|------|------|--------|
| 1 | 创建 ForumLockManager | 🟢 高 |
| 2 | 创建 ForumService | 🟢 高 |
| 3 | 实现输入验证函数 | 🟢 高 |
| 4 | 实现限流器 | 🟡 中 |
| 5 | 实现缓存 | 🟡 中 |
| 6 | 扩展 Router 端点 | 🟢 高 |
| 7 | 单元测试 | 🟡 中 |

---

## 6. 核心文件变更

| 文件 | 变更 |
|------|------|
| `services/forum_service.py` | 新增 |
| `services/__init__.py` | 添加 export |
| `routers/forum.py` | 扩展端点，集成服务 |

---

## 7. 工作量估算

| 任务 | 工作量 |
|------|--------|
| ForumLockManager | 0.5 天 |
| ForumService 核心 | 1 天 |
| 输入验证/限流/缓存 | 0.5 天 |
| API 端点扩展 | 0.5 天 |
| 单元测试 | 0.5 天 |
| **总计** | **3 天** |

---

## 8. 验收标准

- [ ] ForumLockManager 可正确管理并发锁
- [ ] 输入验证可过滤危险字符
- [ ] 限流器可防止滥用
- [ ] 缓存可提升热门帖子读取速度
- [ ] 所有新增 API 端点正常工作
- [ ] 现有 API 保持向后兼容
- [ ] 单元测试覆盖核心逻辑
