From 44405ff7acdce2f55fd1a844b71ea22d5af4a5c4 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 12:01:27 +0800 Subject: [PATCH] feat: enable local docker demo mode --- admin-frontend/.dockerignore | 12 ++ admin-frontend/nginx.conf | 6 + backend/.dockerignore | 17 +++ backend/.env.example | 5 + backend/app/api/auth.py | 4 +- backend/app/core/config.py | 35 +++-- backend/app/services/adapters/__init__.py | 3 + backend/app/services/adapters/demo.py | 151 ++++++++++++++++++++++ backend/app/services/provider_router.py | 55 +++++--- backend/tests/test_provider_router.py | 69 +++++++++- frontend/.dockerignore | 12 ++ frontend/nginx.conf | 8 ++ 12 files changed, 341 insertions(+), 36 deletions(-) create mode 100644 admin-frontend/.dockerignore create mode 100644 backend/.dockerignore create mode 100644 backend/app/services/adapters/demo.py create mode 100644 frontend/.dockerignore diff --git a/admin-frontend/.dockerignore b/admin-frontend/.dockerignore new file mode 100644 index 0000000..1b442a8 --- /dev/null +++ b/admin-frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.env +.env.* +!.env.example +.vite +coverage +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store diff --git a/admin-frontend/nginx.conf b/admin-frontend/nginx.conf index 91ee35b..2f9ab29 100644 --- a/admin-frontend/nginx.conf +++ b/admin-frontend/nginx.conf @@ -25,6 +25,12 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location /auth/ { + proxy_pass http://backend:8000/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # 静态资源代理 location /static/ { proxy_pass http://backend-admin:8001/static/; diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e243852 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,17 @@ +.env +.env.* +!.env.example +.venv +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov +dist +build +*.egg-info +static/images +*.log +.DS_Store diff --git a/backend/.env.example b/backend/.env.example index cfb0d84..9843a3c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,12 +32,17 @@ DEBUG=true # ---------------------------------------------- # [策略配置] # 系统默认使用的供应商列表 (按优先级排序) +# 本地求职演示可设置 ENABLE_DEMO_PROVIDERS=true,在没有真实 API Key 时使用确定性 Demo Provider。 +ENABLE_DEMO_PROVIDERS=false + # 文本生成: 优先 Gemini,其次 OpenAI TEXT_PROVIDERS=["gemini", "openai"] # 图片生成: 优先 CQTAI (Flux/NanoBanana) IMAGE_PROVIDERS=["cqtai"] # 语音生成: 优先 MiniMax,其次 ElevenLabs,最后 EdgeTTS(免费) TTS_PROVIDERS=["minimax", "elevenlabs", "edge_tts"] +# 绘本结构生成: 默认复用 Gemini Storybook adapter +STORYBOOK_PROVIDERS=["storybook_primary"] # [模型参数] TEXT_MODEL=gemini-2.0-flash diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c84d882..505e034 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -255,8 +255,8 @@ async def get_session(user: User | None = Depends(get_current_user)): @router.get("/dev/signin") async def dev_signin(db: AsyncSession = Depends(get_db)): """Developer backdoor login. Only works in DEBUG mode.""" - # if not settings.debug: - # raise HTTPException(status_code=403, detail="Developer login disabled") + if not settings.debug: + raise HTTPException(status_code=403, detail="Developer login disabled") try: return await _handle_oauth_user( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f8d6f80..18d78c3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """应用全局配置""" - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) # 应用基础配置 app_name: str = "DreamWeaver" @@ -49,19 +53,24 @@ class Settings(BaseSettings): tts_edge_voice: str = "zh-CN-XiaoxiaoNeural" antigravity_model: str = "gemini-3-pro-image" - # Provider routing (ordered lists) - text_providers: list[str] = Field(default_factory=lambda: ["gemini"]) - image_providers: list[str] = Field(default_factory=lambda: ["cqtai"]) - tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"]) - story_audio_cache_dir: str = Field( - "storage/audio", - description="Directory for cached story audio files", - ) - - # Celery (Redis) - celery_broker_url: str = Field("redis://localhost:6379/0") + # Provider routing (ordered lists) + text_providers: list[str] = Field(default_factory=lambda: ["gemini"]) + image_providers: list[str] = Field(default_factory=lambda: ["cqtai"]) + tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"]) + storybook_providers: list[str] = Field(default_factory=lambda: ["storybook_primary"]) + enable_demo_providers: bool = Field( + False, + description="Enable local deterministic demo providers for portfolio demos", + ) + story_audio_cache_dir: str = Field( + "storage/audio", + description="Directory for cached story audio files", + ) + + # Celery (Redis) + celery_broker_url: str = Field("redis://localhost:6379/0") celery_result_backend: str = Field("redis://localhost:6379/0") - + # Generic Redis redis_url: str = Field("redis://localhost:6379/0", description="Redis connection URL") redis_sentinel_enabled: bool = Field(False, description="Whether to enable Redis Sentinel") diff --git a/backend/app/services/adapters/__init__.py b/backend/app/services/adapters/__init__.py index 89f4c32..6af2c4f 100644 --- a/backend/app/services/adapters/__init__.py +++ b/backend/app/services/adapters/__init__.py @@ -1,5 +1,7 @@ """适配器模块 - 供应商平台化架构核心。""" +# Demo adapters +from app.services.adapters import demo as _demo_adapters # noqa: F401 from app.services.adapters.base import AdapterConfig, BaseAdapter # Image adapters @@ -15,6 +17,7 @@ from app.services.adapters.text import gemini as _text_gemini_adapter # noqa: F from app.services.adapters.text import openai as _text_openai_adapter # noqa: F401 # TTS adapters +from app.services.adapters.tts import edge_tts as _tts_edge_tts_adapter # noqa: F401 from app.services.adapters.tts import elevenlabs as _tts_elevenlabs_adapter # noqa: F401 from app.services.adapters.tts import minimax as _tts_minimax_adapter # noqa: F401 diff --git a/backend/app/services/adapters/demo.py b/backend/app/services/adapters/demo.py new file mode 100644 index 0000000..bcf7cb4 --- /dev/null +++ b/backend/app/services/adapters/demo.py @@ -0,0 +1,151 @@ +"""Local deterministic demo adapters for portfolio Docker demos.""" + +from __future__ import annotations + +from typing import Literal + +from app.services.adapters.base import BaseAdapter +from app.services.adapters.registry import AdapterRegistry +from app.services.adapters.storybook.primary import Storybook, StorybookPage +from app.services.adapters.text.models import StoryOutput + + +def _compact_topic(data: str) -> str: + parts = [ + item.strip(" ,,。.!!??") + for item in data.replace("\n", " ").split() + if item.strip(" ,,。.!!??") + ] + if parts: + return "、".join(parts[:3]) + return data.strip()[:20] or "星光森林" + + +def _demo_image_data_url(label: str) -> str: + return ( + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' " + "viewBox='0 0 1200 800'%3E%3Crect width='1200' height='800' " + "fill='%23fef3c7'/%3E%3Ccircle cx='250' cy='180' r='130' " + "fill='%23f59e0b'/%3E%3Cpath d='M120 600C320 420 520 700 " + "720 500S1020 420 1100 560' fill='none' stroke='%237c3aed' " + "stroke-width='42'/%3E%3C/svg%3E" + ) + + +@AdapterRegistry.register("text", "demo") +class DemoTextAdapter(BaseAdapter[StoryOutput]): + """Generate a stable local story without external AI services.""" + + adapter_type = "text" + adapter_name = "demo" + + async def execute( + self, + input_type: Literal["keywords", "full_story"], + data: str, + education_theme: str | None = None, + memory_context: str | None = None, + **kwargs, + ) -> StoryOutput: + theme = education_theme or "勇气与想象力" + topic = _compact_topic(data) + title = f"{topic}的晚安冒险" + protagonist = "小星" + if memory_context and "名字" in memory_context: + protagonist = "故事里的小朋友" + + story_text = ( + f"睡前,{protagonist}把“{topic}”写在一张小纸条上,轻轻放进枕头下面。" + f"月光刚好照进房间,纸条变成了一只会发光的小船,邀请{protagonist}去寻找" + f"关于“{theme}”的答案。\n\n" + f"小船穿过云朵河,来到一片会唱歌的森林。森林里的小鹿说:真正的勇敢不是" + f"一点也不害怕,而是害怕的时候,还愿意牵住朋友的手往前走。{protagonist}" + f"听完以后,把一颗星星种进土里,星星长成了照亮回家路的小灯。\n\n" + f"回到房间时,纸条已经变成了一枚金色书签。{protagonist}把它夹进故事书," + f"决定明天也带着好奇心和温柔,继续新的冒险。" + ) + + return StoryOutput( + mode="generated" if input_type == "keywords" else "enhanced", + title=title, + story_text=story_text, + cover_prompt_suggestion=f"温暖水彩儿童绘本封面,主题:{topic},{theme}", + ) + + async def health_check(self) -> bool: + return True + + @property + def estimated_cost(self) -> float: + return 0.0 + + +@AdapterRegistry.register("image", "demo") +class DemoImageAdapter(BaseAdapter[str]): + """Return a compact SVG data URL as a generated-image placeholder.""" + + adapter_type = "image" + adapter_name = "demo" + + async def execute(self, prompt: str, **kwargs) -> str: + return _demo_image_data_url(prompt) + + async def health_check(self) -> bool: + return True + + @property + def estimated_cost(self) -> float: + return 0.0 + + +@AdapterRegistry.register("storybook", "demo") +class DemoStorybookAdapter(BaseAdapter[Storybook]): + """Generate a stable local storybook without external AI services.""" + + adapter_type = "storybook" + adapter_name = "demo" + + async def execute( + self, + keywords: str, + page_count: int = 6, + education_theme: str | None = None, + memory_context: str | None = None, + **kwargs, + ) -> Storybook: + theme = education_theme or "勇气" + topic = _compact_topic(keywords) + page_count = max(4, min(page_count, 8)) + page_texts = [ + f"小星在枕头下发现一张写着“{topic}”的星光地图。", + "地图带他来到云朵河边,一只小船正在等他上船。", + f"森林里的小鹿告诉他:{theme}藏在每一次温柔的选择里。", + "小星把一颗星星种进土里,星星长成了回家的灯。", + "他把今天的发现画进故事本,准备明天讲给家人听。", + "月亮轻轻合上窗帘,房间里只剩下甜甜的梦。", + "星光地图变成书签,陪他继续下一次冒险。", + "每个勇敢的小问题,都能长出一个温暖的故事。", + ] + pages = [ + StorybookPage( + page_number=index + 1, + text=page_texts[index], + image_prompt=f"温暖儿童绘本插画,第 {index + 1} 页,{topic},{theme}", + ) + for index in range(page_count) + ] + + return Storybook( + title=f"{topic}的星光绘本", + main_character="小星", + art_style="温暖水彩", + pages=pages, + cover_prompt=f"温暖水彩儿童绘本封面,{topic},{theme}", + ) + + async def health_check(self) -> bool: + return True + + @property + def estimated_cost(self) -> float: + return 0.0 diff --git a/backend/app/services/provider_router.py b/backend/app/services/provider_router.py index e75f189..a789d16 100644 --- a/backend/app/services/provider_router.py +++ b/backend/app/services/provider_router.py @@ -33,14 +33,13 @@ class RoutingStrategy(str, Enum): ROUND_ROBIN = "round_robin" # 轮询 -# 默认配置映射(当 DB 无配置时使用) # 默认配置映射(当 DB 无配置时使用) # 这是“代码级”的默认策略,对应 .env 为空的情况 DEFAULT_PROVIDERS: dict[ProviderType, list[str]] = { "text": ["gemini", "openai"], "image": ["cqtai"], "tts": ["minimax", "elevenlabs", "edge_tts"], - "storybook": ["gemini"], + "storybook": ["storybook_primary"], } # API Key 映射:adapter_name -> settings 属性名 @@ -88,6 +87,13 @@ def _get_api_key(config_ref: str | None, adapter_name: str) -> str: def _get_default_config(adapter_name: str) -> AdapterConfig | None: """获取适配器的默认配置(无 DB 记录时使用)。返回 None 表示未知适配器。""" + if adapter_name == "demo": + return AdapterConfig( + api_key="", + model="demo", + timeout_ms=1000, + ) + # --- Text Defaults --- if adapter_name in ("gemini", "text_primary"): return AdapterConfig( @@ -103,7 +109,7 @@ def _get_default_config(adapter_name: str) -> AdapterConfig | None: ) # --- Image Defaults --- - if adapter_name in ("cqtai"): + if adapter_name == "cqtai": return AdapterConfig( api_key=getattr(settings, "cqtai_api_key", ""), model=settings.image_model or "nano-banana-pro", @@ -194,8 +200,12 @@ async def _get_providers_with_config( "text": settings.text_providers, "image": settings.image_providers, "tts": settings.tts_providers, + "storybook": settings.storybook_providers, } names = settings_map.get(provider_type) or DEFAULT_PROVIDERS[provider_type] + if settings.enable_demo_providers and "demo" not in names: + names = ["demo", *names] + result = [] for name in names: config = _get_default_config(name) @@ -273,12 +283,17 @@ async def _route_with_failover( # 按策略排序 sorted_providers = _sort_by_strategy(providers, strategy, provider_type) - # 如果有 db 会话,过滤掉熔断的供应商 + # 如果有 db 会话,过滤掉后台管理台中已熔断的供应商。 + # .env/default provider 没有 providers 表记录,不能写入带外键的健康表。 if db: healthy_providers = [] for item in sorted_providers: name, config, db_provider = item - provider_id = db_provider.id if db_provider else name + if db_provider is None: + healthy_providers.append(item) + continue + + provider_id = db_provider.id if await health_checker.is_healthy(db, provider_id): healthy_providers.append(item) else: @@ -295,7 +310,7 @@ async def _route_with_failover( errors.append(f"{name}: 适配器未注册") continue - provider_id = db_provider.id if db_provider else name + provider_id = db_provider.id if db_provider else None try: logger.debug( @@ -315,8 +330,8 @@ async def _route_with_failover( # 更新延迟缓存 _latency_cache[name] = latency_ms - # 记录成功指标 - if db: + # 记录成功指标。Provider 指标/健康表带外键,只记录后台管理台里的真实 provider。 + if db and db_provider and provider_id: await metrics_collector.record_call( db, provider_id=provider_id, @@ -326,16 +341,16 @@ async def _route_with_failover( ) await health_checker.record_call_result(db, provider_id, success=True) - # 记录用户成本 - if user_id: - await cost_tracker.record_cost( - db, - user_id=user_id, - provider_name=name, - capability=provider_type, - estimated_cost=adapter.estimated_cost, - provider_id=provider_id if db_provider else None, - ) + # 记录用户成本;环境变量/default provider 没有 provider_id,保留 provider_name 即可。 + if db and user_id: + await cost_tracker.record_cost( + db, + user_id=user_id, + provider_name=name, + capability=provider_type, + estimated_cost=adapter.estimated_cost, + provider_id=provider_id, + ) logger.info( "provider_success", @@ -355,8 +370,8 @@ async def _route_with_failover( ) errors.append(f"{name}: {exc}") - # 记录失败指标 - if db: + # 记录失败指标。Provider 指标/健康表带外键,只记录后台管理台里的真实 provider。 + if db and db_provider and provider_id: await metrics_collector.record_call( db, provider_id=provider_id, diff --git a/backend/tests/test_provider_router.py b/backend/tests/test_provider_router.py index 3d61a67..3bacd29 100644 --- a/backend/tests/test_provider_router.py +++ b/backend/tests/test_provider_router.py @@ -125,6 +125,70 @@ class TestProviderFailover: data="测试", ) + @pytest.mark.asyncio + async def test_default_provider_skips_fk_backed_metrics(self): + """环境变量/default provider 没有 providers 表记录,不写带外键的指标表。""" + from app.services import provider_router + + mock_result = StoryOutput( + mode="generated", + title="本地演示故事", + story_text="内容", + cover_prompt_suggestion="prompt", + ) + + class MockAdapter: + estimated_cost = 0.0 + + def __init__(self, config): + self.config = config + + async def execute(self, **kwargs): + return mock_result + + with patch.object( + provider_router, + "_get_providers_with_config", + new_callable=AsyncMock, + ) as mock_providers: + mock_providers.return_value = [("demo", AdapterConfig(api_key=""), None)] + + with patch.object(provider_router.AdapterRegistry, "get", return_value=MockAdapter): + with patch.object( + provider_router.health_checker, + "is_healthy", + new_callable=AsyncMock, + ) as mock_is_healthy: + with patch.object( + provider_router.metrics_collector, + "record_call", + new_callable=AsyncMock, + ) as mock_record_call: + with patch.object( + provider_router.health_checker, + "record_call_result", + new_callable=AsyncMock, + ) as mock_record_call_result: + with patch.object( + provider_router.cost_tracker, + "record_cost", + new_callable=AsyncMock, + ) as mock_record_cost: + result = await provider_router._route_with_failover( + "text", + db=AsyncMock(), + user_id="user-1", + input_type="keywords", + data="测试", + ) + + assert result == mock_result + mock_is_healthy.assert_not_called() + mock_record_call.assert_not_called() + mock_record_call_result.assert_not_called() + mock_record_cost.assert_awaited_once() + assert mock_record_cost.await_args.kwargs["provider_id"] is None + class TestProviderConfigFromDB: """从 DB 加载 provider 配置测试。""" @@ -187,7 +251,10 @@ class TestProviderCacheStartup: mock_factory.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_factory.return_value.__aexit__ = AsyncMock() - with patch("app.services.provider_cache.reload_providers", new_callable=AsyncMock) as mock_reload: + with patch( + "app.services.provider_cache.reload_providers", + new_callable=AsyncMock, + ) as mock_reload: mock_reload.return_value = {"text": [], "image": [], "tts": []} await _load_provider_cache() diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1b442a8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.env +.env.* +!.env.example +.vite +coverage +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7dca59e..cd14df4 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -18,6 +18,14 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # 登录/OAuth 回调代理 + location /auth/ { + proxy_pass http://backend:8000/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # 静态资源代理 (后端生成的图片) location /static/ { proxy_pass http://backend:8000/static/;