You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

179 lines
5.5 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
===================================
FastAPI 应用工厂模块
===================================
职责:
1. 创建和配置 FastAPI 应用实例
2. 配置 CORS 中间件
3. 注册路由和异常处理器
4. 托管前端静态文件(生产模式)
使用方式:
from api.app import create_app
app = create_app()
"""
import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from api.v1 import api_v1_router
from api.middlewares.error_handler import add_error_handlers
from api.v1.schemas.common import RootResponse, HealthResponse
from src.services.system_config_service import SystemConfigService
@asynccontextmanager
async def app_lifespan(app: FastAPI):
"""Initialize and release shared services for the app lifecycle."""
app.state.system_config_service = SystemConfigService()
try:
yield
finally:
if hasattr(app.state, "system_config_service"):
delattr(app.state, "system_config_service")
def create_app(static_dir: Optional[Path] = None) -> FastAPI:
"""
创建并配置 FastAPI 应用实例
Args:
static_dir: 静态文件目录路径(可选,默认为项目根目录下的 static
Returns:
配置完成的 FastAPI 应用实例
"""
# 默认静态文件目录
if static_dir is None:
static_dir = Path(__file__).parent.parent / "static"
# 创建 FastAPI 实例
app = FastAPI(
title="Daily Stock Analysis API",
description=(
"A股/港股/美股自选股智能分析系统 API\n\n"
"## 功能模块\n"
"- 股票分析:触发 AI 智能分析\n"
"- 历史记录:查询历史分析报告\n"
"- 股票数据:获取行情数据\n\n"
"## 认证方式\n"
"当前版本暂无认证要求"
),
version="1.0.0",
lifespan=app_lifespan,
)
# ============================================================
# CORS 配置
# ============================================================
allowed_origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
# 从环境变量添加额外的允许来源
extra_origins = os.environ.get("CORS_ORIGINS", "")
if extra_origins:
allowed_origins.extend([o.strip() for o in extra_origins.split(",") if o.strip()])
# 允许所有来源(开发/演示用)
if os.environ.get("CORS_ALLOW_ALL", "").lower() == "true":
allowed_origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================
# 注册路由
# ============================================================
app.include_router(api_v1_router)
add_error_handlers(app)
# ============================================================
# 根路由和健康检查
# ============================================================
has_frontend = static_dir.exists() and (static_dir / "index.html").exists()
if has_frontend:
@app.get("/", include_in_schema=False)
async def root():
"""根路由 - 返回前端页面"""
return FileResponse(static_dir / "index.html")
else:
@app.get(
"/",
response_model=RootResponse,
tags=["Health"],
summary="API 根路由",
description="返回 API 运行状态信息"
)
async def root() -> RootResponse:
"""根路由 - API 状态信息"""
return RootResponse(
message="Daily Stock Analysis API is running",
version="1.0.0"
)
@app.get(
"/api/health",
response_model=HealthResponse,
tags=["Health"],
summary="健康检查",
description="用于负载均衡器或监控系统检查服务状态"
)
async def health_check() -> HealthResponse:
"""健康检查接口"""
return HealthResponse(
status="ok",
timestamp=datetime.now().isoformat()
)
# ============================================================
# 静态文件托管(前端 SPA
# ============================================================
if has_frontend:
# 挂载静态资源目录
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
# SPA 路由回退
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(request: Request, full_path: str):
"""SPA 路由回退 - 非 API 路由返回 index.html"""
if full_path.startswith("api/"):
return None
file_path = static_dir / full_path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
return FileResponse(static_dir / "index.html")
return app
# 默认应用实例(供 uvicorn 直接使用)
app = create_app()