diff --git a/ai_knowledge_base_v2/apps/backend/app/core/database.py b/ai_knowledge_base_v2/apps/backend/app/core/database.py index 5dc1385..01c942c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/database.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/database.py @@ -10,15 +10,15 @@ from app.models import Base settings = get_settings() -engine = create_engine( - settings.database_url, - pool_pre_ping=True, - pool_size=settings.db_pool_size, - max_overflow=settings.db_max_overflow, - pool_timeout=settings.db_pool_timeout, - pool_recycle=settings.db_pool_recycle, - future=True, -) +engine_options = {"pool_pre_ping": True, "future": True} +if not settings.database_url.startswith("sqlite"): + engine_options.update( + pool_size=settings.db_pool_size, + max_overflow=settings.db_max_overflow, + pool_timeout=settings.db_pool_timeout, + pool_recycle=settings.db_pool_recycle, + ) +engine = create_engine(settings.database_url, **engine_options) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) diff --git a/ai_knowledge_base_v2/apps/backend/requirements-dev.txt b/ai_knowledge_base_v2/apps/backend/requirements-dev.txt new file mode 100644 index 0000000..1441c92 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==9.1.1 diff --git a/ai_knowledge_base_v2/apps/backend/requirements.txt b/ai_knowledge_base_v2/apps/backend/requirements.txt index 8569205..f2ce035 100644 --- a/ai_knowledge_base_v2/apps/backend/requirements.txt +++ b/ai_knowledge_base_v2/apps/backend/requirements.txt @@ -1,16 +1,16 @@ -fastapi>=0.116.0 -uvicorn[standard]>=0.35.0 -SQLAlchemy>=2.0.40 -alembic>=1.16.0 -pydantic>=2.11.0 -pydantic-settings>=2.10.0 -python-dotenv>=1.1.0 -PyJWT>=2.10.0 -PyMySQL>=1.1.1 -cryptography>=45.0.0 -httpx>=0.28.0 -redis>=5.0.0 -openpyxl>=3.1.5 -python-multipart>=0.0.20 -alibabacloud_dysmsapi20170525>=4.1.2 -Pillow>=11.0.0 +fastapi==0.139.0 +uvicorn[standard]==0.50.1 +SQLAlchemy==2.0.51 +alembic==1.18.5 +pydantic==2.13.4 +pydantic-settings==2.14.2 +python-dotenv==1.2.2 +PyJWT==2.13.0 +PyMySQL==1.2.0 +cryptography==46.0.5 +httpx==0.28.1 +redis==7.4.1 +openpyxl==3.1.5 +python-multipart==0.0.32 +alibabacloud-dysmsapi20170525==4.6.0 +Pillow==12.3.0 diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py new file mode 100644 index 0000000..6645674 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date +import asyncio +import json + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.api import chat +from app.models import Base +from app.models.ai_config import SystemConfig +from app.models.user import User +from app.services.chat_queue_runtime import ChatQueueLease +from app.services.chat_queue_service import ChatQueueConfig, QueueRequest +from app.services.chat_service import ChatService +from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService +from app.services.security_state_service import SecurityStateService + + +@pytest.fixture(autouse=True) +def reset_security_state(monkeypatch): + monkeypatch.setattr("app.services.security_state_service.get_sync_redis_client", lambda: None) + SecurityStateService._counters.clear() + + +def test_secret_is_encrypted_and_masked(): + encrypted = SecretService.encrypt("production-secret") + assert encrypted.startswith(ENCRYPTED_PREFIX) + assert "production-secret" not in encrypted + assert SecretService.decrypt(encrypted) == "production-secret" + assert SecretService.masked(encrypted) == MASKED_SECRET + + +def test_rate_limit_blocks_after_limit(): + SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many") + SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many") + with pytest.raises(HTTPException) as exc: + SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many") + assert exc.value.status_code == 429 + + +def test_revoked_token_is_shared_security_state(): + SecurityStateService.revoke_token("token-id", None) + assert SecurityStateService.is_token_revoked("token-id") is True + + +def test_daily_quota_resets_for_new_day(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + with Session(engine) as db: + user = User( + id=1, + phone="13800000001", + name="测试用户", + daily_chat_limit=10, + daily_chat_used=10, + daily_chat_reset_date=date(2020, 1, 1), + ) + db.add(user) + db.commit() + prepared = ChatService.prepare_daily_quota(db, user) + assert prepared.daily_chat_used == 0 + assert prepared.daily_chat_reset_date > date(2020, 1, 1) + + +def test_queued_chat_reports_position_and_completes(monkeypatch): + config = ChatQueueConfig(1, 10, 10, 60) + queued = ChatQueueLease("memory", QueueRequest("queued", token=object(), position=2, active_count=1, waiting_count=2)) + acquired = ChatQueueLease("memory", QueueRequest("acquired", token=queued.request.token, active_count=1)) + released = False + + async def request_slot(_config): + return queued + + async def wait_slot(_lease, _config): + return acquired + + async def release_slot(_lease): + nonlocal released + released = True + + async def stream_answer(*_args, **_kwargs): + yield "回答" + + monkeypatch.setattr(chat, "load_chat_queue_config", lambda _db: config) + monkeypatch.setattr(chat, "request_chat_slot", request_slot) + monkeypatch.setattr(chat, "wait_for_chat_slot", wait_slot) + monkeypatch.setattr(chat, "release_chat_slot", release_slot) + monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer) + + payload = type("Payload", (), {"sessionId": 1, "message": "问题"})() + async def collect_events(): + return [item async for item in chat._chat_stream(payload, object(), object())] + + events = asyncio.run(collect_events()) + decoded = [json.loads(item.removeprefix("data: ")) for item in events if item != "data: [DONE]\n\n"] + assert decoded[0]["type"] == "queued" + assert any(item["type"] == "content" and item["content"] == "回答" for item in decoded) + assert released is True + + +def test_sensitive_system_config_never_returns_plaintext(): + from app.api.admin_settings import _config_dict + + config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret") + result = _config_dict(config) + assert result["configValue"] == MASKED_SECRET diff --git a/ai_knowledge_base_v2/scripts/phase6_verify.sh b/ai_knowledge_base_v2/scripts/phase6_verify.sh index 1ea10b8..a6d684e 100755 --- a/ai_knowledge_base_v2/scripts/phase6_verify.sh +++ b/ai_knowledge_base_v2/scripts/phase6_verify.sh @@ -15,6 +15,9 @@ print(f"OpenAPI paths: {len(paths)}") assert len(paths) >= 32 PY +echo "== 后端关键测试 ==" +./.venv/bin/python -m pytest -q + echo "== Alembic MySQL 静态 SQL 检查 ==" ./.venv/bin/alembic upgrade head --sql >/tmp/ai_kb_v2_migration.sql wc -l /tmp/ai_kb_v2_migration.sql @@ -31,4 +34,16 @@ echo "== Docker Compose 配置检查 ==" cd "$ROOT_DIR" docker compose -f docker-compose.dev.yml config --quiet +echo "== 生产 Docker Compose 配置检查 ==" +MYSQL_ROOT_PASSWORD=test-root \ +MYSQL_PASSWORD=test-db \ +REDIS_PASSWORD=test-redis \ +DATABASE_URL='mysql+pymysql://ai_kb:test-db@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4' \ +REDIS_URL='redis://:test-redis@redis:6379/0' \ +JWT_SECRET_KEY='test-jwt-secret-at-least-32-characters' \ +CONFIG_ENCRYPTION_KEY='MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=' \ +BOOTSTRAP_ADMIN_USERNAME=admin \ +BOOTSTRAP_ADMIN_PASSWORD='Strong-Test-Password-2026' \ +docker compose -f docker-compose.prod.yml config --quiet + echo "阶段六自动验证完成"