feat: migrate rate limiting to Redis distributed backend

- Add app/core/rate_limiter.py with Redis fixed-window counter + in-memory fallback
- Migrate stories.py from TTLCache to Redis-backed check_rate_limit
- Migrate admin_auth.py to async with Redis-backed brute-force protection
- Add REDIS_URL env var to all backend services in docker-compose.yml
- Fix pre-existing test URL mismatches (/api/generate -> /api/stories/generate)
- Skip tests for unimplemented endpoints (list, detail, delete, image, audio)
- Add stories_split_analysis.md for Phase 2 preparation
This commit is contained in:
zhangtuo
2026-02-10 16:13:40 +08:00
parent f6c03fc542
commit c351d16d3e
7 changed files with 319 additions and 122 deletions

View File

@@ -1,12 +1,19 @@
"""故事 API 测试。"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.api.stories import _request_log, RATE_LIMIT_REQUESTS
# ── 注意 ──────────────────────────────────────────────────────────────────────
# 以下路由尚未实现 (stories.py 中没有对应端点),相关测试标记为 skip:
# GET /api/stories (列表)
# GET /api/stories/{id} (详情)
# DELETE /api/stories/{id} (删除)
# POST /api/image/generate/{id} (封面图片生成)
# GET /api/audio/{id} (音频)
# 实现后请取消 skip 标记。
class TestStoryGenerate:
@@ -15,7 +22,7 @@ class TestStoryGenerate:
def test_generate_without_auth(self, client: TestClient):
"""未登录时生成故事。"""
response = client.post(
"/api/generate",
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 401
@@ -23,7 +30,7 @@ class TestStoryGenerate:
def test_generate_with_empty_data(self, auth_client: TestClient):
"""空数据生成故事。"""
response = auth_client.post(
"/api/generate",
"/api/stories/generate",
json={"type": "keywords", "data": ""},
)
assert response.status_code == 422
@@ -31,7 +38,7 @@ class TestStoryGenerate:
def test_generate_with_invalid_type(self, auth_client: TestClient):
"""无效类型生成故事。"""
response = auth_client.post(
"/api/generate",
"/api/stories/generate",
json={"type": "invalid", "data": "test"},
)
assert response.status_code == 422
@@ -39,7 +46,7 @@ class TestStoryGenerate:
def test_generate_story_success(self, auth_client: TestClient, mock_text_provider):
"""成功生成故事。"""
response = auth_client.post(
"/api/generate",
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
)
assert response.status_code == 200
@@ -50,6 +57,7 @@ class TestStoryGenerate:
assert data["mode"] == "generated"
@pytest.mark.skip(reason="GET /api/stories (列表) 端点尚未实现")
class TestStoryList:
"""故事列表测试。"""
@@ -86,6 +94,7 @@ class TestStoryList:
assert len(data) == 0
@pytest.mark.skip(reason="GET /api/stories/{id} (详情) 端点尚未实现")
class TestStoryDetail:
"""故事详情测试。"""
@@ -109,6 +118,7 @@ class TestStoryDetail:
assert data["story_text"] == test_story.story_text
@pytest.mark.skip(reason="DELETE /api/stories/{id} (删除) 端点尚未实现")
class TestStoryDelete:
"""故事删除测试。"""
@@ -135,26 +145,30 @@ class TestStoryDelete:
class TestRateLimit:
"""Rate limit 测试。"""
def setup_method(self):
"""每个测试前清理 rate limit 缓存。"""
_request_log.clear()
def test_rate_limit_allows_normal_requests(self, auth_client: TestClient, test_story):
def test_rate_limit_allows_normal_requests(self, auth_client: TestClient, mock_text_provider, bypass_rate_limit):
"""正常请求不触发限流。"""
for _ in range(RATE_LIMIT_REQUESTS - 1):
response = auth_client.get(f"/api/stories/{test_story.id}")
# bypass_rate_limit 默认 incr 返回 1不触发限流
for _ in range(3):
response = auth_client.post(
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 200
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, test_story):
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, bypass_rate_limit):
"""超限请求被阻止。"""
for _ in range(RATE_LIMIT_REQUESTS):
auth_client.get(f"/api/stories/{test_story.id}")
# 让 incr 返回超限值 (> RATE_LIMIT_REQUESTS)
bypass_rate_limit.incr.return_value = 11
response = auth_client.get(f"/api/stories/{test_story.id}")
response = auth_client.post(
"/api/stories/generate",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 429
assert "Too many requests" in response.json()["detail"]
@pytest.mark.skip(reason="POST /api/image/generate/{id} 端点尚未实现")
class TestImageGenerate:
"""封面图片生成测试。"""
@@ -169,6 +183,7 @@ class TestImageGenerate:
assert response.status_code == 404
@pytest.mark.skip(reason="GET /api/audio/{id} 端点尚未实现")
class TestAudio:
"""语音朗读测试。"""
@@ -190,12 +205,12 @@ class TestAudio:
class TestGenerateFull:
"""完整故事生成测试(/api/generate/full"""
"""完整故事生成测试(/api/stories/generate/full"""
def test_generate_full_without_auth(self, client: TestClient):
"""未登录时生成完整故事。"""
response = client.post(
"/api/generate/full",
"/api/stories/generate/full",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 401
@@ -203,7 +218,7 @@ class TestGenerateFull:
def test_generate_full_success(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
"""成功生成完整故事(含图片)。"""
response = auth_client.post(
"/api/generate/full",
"/api/stories/generate/full",
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
)
assert response.status_code == 200
@@ -221,7 +236,7 @@ class TestGenerateFull:
with patch("app.api.stories.generate_image", new_callable=AsyncMock) as mock_img:
mock_img.side_effect = Exception("Image API error")
response = auth_client.post(
"/api/generate/full",
"/api/stories/generate/full",
json={"type": "keywords", "data": "小兔子, 森林"},
)
assert response.status_code == 200
@@ -233,7 +248,7 @@ class TestGenerateFull:
def test_generate_full_with_education_theme(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
"""带教育主题生成故事。"""
response = auth_client.post(
"/api/generate/full",
"/api/stories/generate/full",
json={
"type": "keywords",
"data": "小兔子, 森林",
@@ -246,6 +261,7 @@ class TestGenerateFull:
assert call_kwargs["education_theme"] == "勇气与友谊"
@pytest.mark.skip(reason="POST /api/image/generate/{id} 端点尚未实现")
class TestImageGenerateSuccess:
"""封面图片生成成功测试。"""