Files
stock/serve/backend/app/main.py
T
fish 580743853a serve 设为只读模式,禁用外部数据拉取
serve 端不再启动 pull_scheduler 和 financial_scheduler,
阻塞 ext_data 和 financials 的外部获取/同步 API 端点,
数据仅通过 local→serve 同步推送。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:04:46 +08:00

219 lines
8.2 KiB
Python

"""FastAPI 入口。"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import analysis, auth as auth_api, data, data_sync, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
from app.tickflow.repository import DataStore, KlineRepository
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(
"TickFlow Stock Panel v%s starting (mode=%s)",
__version__, tf_client.current_mode(),
)
# 首次启动: 若配置了 AUTH_PASSWORD 环境变量且未设过密码, 用它初始化。
# 公网部署免 SSH 端口转发; 已设过密码则不覆盖 (改密码走 UI)。
try:
from app.services import auth as auth_service
auth_service.bootstrap_from_env()
except Exception as e: # noqa: BLE001
logger.warning("auth bootstrap failed: %s", e)
# 数据层
store = DataStore()
repo = KlineRepository(store)
app.state.datastore = store
app.state.repo = repo
# Polars 缓存预热
repo.refresh_cache()
# 能力探测
capset = detect_capabilities()
app.state.capabilities = capset
logger.info("ready; %d capabilities active", len(capset.all()))
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
try:
scheduler = daily_pipeline.start_scheduler(repo, capset)
app.state.scheduler = scheduler
except Exception as e: # noqa: BLE001
logger.warning("scheduler not started: %s", e)
app.state.scheduler = None
# 内置扩展表 (概念/行业): 只创建 config 供 sync 解压时用, 不拉数据。
try:
from app.services.ext_presets import ensure_builtin_presets
await ensure_builtin_presets(store.data_dir)
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
# 策略引擎
from app.strategy.engine import StrategyEngine
from app.services.screener import ScreenerService
_screener_svc = ScreenerService(repo)
strategy_dirs = [
Path(__file__).resolve().parent / "strategy" / "builtin",
store.data_dir / "strategies" / "custom",
store.data_dir / "strategies" / "ai",
]
strategy_engine = StrategyEngine(
enriched_loader=_screener_svc._load_enriched_for_date,
enriched_history_loader=_screener_svc._load_enriched_history,
strategy_dirs=strategy_dirs,
)
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
yield
if app.state.scheduler:
app.state.scheduler.shutdown(wait=False)
logger.info("shutdown")
app = FastAPI(
title="TickFlow Stock Panel",
version=__version__,
description="A 股选股 + 回测面板 — TickFlow 适配",
lifespan=lifespan,
)
# CORS: 允许局域网访问 (自托管场景, 放开所有来源)
# 注: allow_credentials=True 与 allow_origins=['*'] 不能共存 (浏览器规范),
# 本项目认证走 header (API Key), 不依赖 cookie, 故关闭 credentials 换取通配来源。
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ================================================================
# 访问认证中间件
# ================================================================
# 拦截所有 /api/ 请求, 三种状态:
# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码)
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
# 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转
if not path.startswith("/api/"):
return await call_next(request)
# 白名单放行(设密码/登录/探活本身不拦)
if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT:
return await call_next(request)
from app.services import auth as auth_service
# 情况 1+2: 未设密码
if not auth_service.is_configured():
# 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码)
if auth_api._is_local_network(auth_api._client_ip(request)):
return await call_next(request)
# 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占)
return JSONResponse(
status_code=403,
content={
"detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码",
"code": "NOT_INITIALIZED",
},
)
# 情况 3: 已设密码, 检查会话
token = request.cookies.get(auth_api.COOKIE_NAME)
if token and auth_service.is_valid_session(token):
return await call_next(request)
# 未登录: 401(前端跳登录页)
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
# 路由
app.include_router(core_router)
app.include_router(auth_api.router)
app.include_router(kline.router)
app.include_router(screener.router)
app.include_router(indices.router)
app.include_router(overview.router)
app.include_router(analysis.router)
app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(data_sync.router)
app.include_router(ext_data.router)
app.include_router(financials.router)
app.include_router(stock_analysis.router)
app.include_router(market_recap.router)
app.include_router(settings_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
app.include_router(alerts.router)
app.include_router(rps.router)
# 能力门控异常 → 403(而非默认 500)
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。
from fastapi import Request
from fastapi.responses import JSONResponse
from app.tickflow.capabilities import CapabilityDenied
@app.exception_handler(CapabilityDenied)
async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse:
return JSONResponse(
status_code=403,
content={"detail": str(exc), "suggestion": exc.suggestion},
)
# 生产期静态文件(前端 dist)
_static = Path(settings.static_dir)
if _static.exists():
if (_static / "assets").exists():
app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str): # noqa: ARG001
"""所有未匹配路径回退到 index.html — React Router 接管。
index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到
最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。
"""
index = _static / "index.html"
if index.exists():
return FileResponse(
index,
headers={"Cache-Control": "no-store, must-revalidate"},
)
return {"error": "frontend not built"}