951d2ce926
- 环境变量 DOCS_USERNAME(默认 admin)/ DOCS_PASSWORD 控制 - 未配置 DOCS_PASSWORD 时文档端点返回 404(隐藏存在) - 密码校验用 secrets.compare_digest 防时序攻击 - 应用层中间件,所有入口(含直连 8090)均生效
215 lines
8.3 KiB
Python
215 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""FastAPI 应用入口(接口层组装)。
|
||
|
||
启动方式(项目根目录):
|
||
uv run uvicorn app.main:app --host 127.0.0.1 --port 8090
|
||
或使用脚本:``uv run python -m app``
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import os
|
||
import secrets
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import Response
|
||
|
||
from app import config
|
||
from app.infrastructure.repositories import Database
|
||
from app.api.routers import agents as agents_router
|
||
from app.api.routers import auth as auth_router
|
||
from app.api.routers import bootstrap as bootstrap_router
|
||
from app.api.routers import rbac_admin as rbac_admin_router
|
||
from app.api.routers import rbac_hall as rbac_hall_router
|
||
from app.api.routers import rbac_service_market as rbac_service_market_router
|
||
from app.api.routers import rbac_pages as rbac_pages_router
|
||
from app.api.routers import rbac_opc as rbac_opc_router
|
||
from app.api.routers import rbac_operator as rbac_operator_router
|
||
from app.api.routers import rbac_agents as rbac_agents_router
|
||
from app.api.routers import rbac_public as rbac_public_router
|
||
from app.api.routers import rbac_org as rbac_org_router
|
||
from app.api.routers import rbac_permissions as rbac_permissions_router
|
||
from app.api.routers import rbac_portals as rbac_portals_router
|
||
from app.api.routers import rbac_certifications as rbac_certifications_router
|
||
from app.api.routers import rbac_training as rbac_training_router
|
||
from app.api.routers import rbac_credit as rbac_credit_router
|
||
from app.api.routers import rbac_ecosystem as rbac_ecosystem_router
|
||
from app.api.routers import rbac_enterprise as rbac_enterprise_router
|
||
from app.api.routers import rbac_ent_onboard as rbac_ent_onboard_router
|
||
from app.api.routers import rbac_teams as rbac_teams_router
|
||
from app.api.routers import rbac_desktop_updates as rbac_desktop_updates_router
|
||
from app.api.routers import rbac_moderation as rbac_moderation_router
|
||
from app.api.routers import templates as templates_router
|
||
from app.api.routers import relay as relay_router
|
||
from app.pay import routers as pay_router
|
||
from app.market import routers as market_router
|
||
from app.incubator import routers as incubator_router
|
||
from app.api.routers import invite as invite_router
|
||
from app.api.routers import rbac_compute_pricing as compute_pricing_router
|
||
from app.api.routers import rbac_compute_assets as compute_assets_router
|
||
from app.api.routers import agent_gate as agent_gate_router
|
||
from app.im import router as im_router
|
||
|
||
APP_NAME = "云南省超级个体服务平台"
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""启动时初始化数据库(建表 + 幂等种子)+ Redis 缓存连接。
|
||
|
||
若 ``app.state.db`` 已由外部注入(如测试夹具),则直接复用,
|
||
不重新创建,避免覆盖调用方准备的数据库实例。
|
||
"""
|
||
from .infrastructure.cache import cache as redis_cache
|
||
db = getattr(app.state, "db", None)
|
||
if db is None:
|
||
db = Database()
|
||
await db.initialize()
|
||
app.state.db = db
|
||
# 连接 Redis(失败自动降级为无缓存)
|
||
await redis_cache.connect()
|
||
app.state.cache = redis_cache
|
||
yield
|
||
# 关闭 Redis
|
||
await redis_cache.close()
|
||
if db is not None and db._owns_session:
|
||
await db.close()
|
||
# 释放引擎连接池,避免连接残留导致下次启动 initialize() 被锁挂起/连接泄漏
|
||
engine = getattr(db, "_engine", None)
|
||
if engine is not None:
|
||
try:
|
||
await engine.dispose()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
app = FastAPI(
|
||
title=APP_NAME,
|
||
description=(
|
||
"云超服核心服务端:身份/平台 API + 培训子应用(/api/*),统一对外 opc.pinesound.cn。"
|
||
"异步四层架构(接口/业务/领域/基础设施)。"
|
||
),
|
||
version="0.1.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# 演示服务允许任意来源(实际生产由主后端网关收敛)。
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 文档页面访问控制(HTTP Basic Auth)
|
||
# 环境变量:
|
||
# DOCS_USERNAME 用户名,默认 admin
|
||
# DOCS_PASSWORD 密码;为空时文档端点返回 404(隐藏存在),生产必须设置
|
||
# 保护路径:/docs、/redoc、/openapi.json(含 /docs/、/redoc/ 子路径)
|
||
# ---------------------------------------------------------------------------
|
||
_DOCS_USERNAME = os.getenv("DOCS_USERNAME", "admin").strip()
|
||
_DOCS_PASSWORD = os.getenv("DOCS_PASSWORD", "").strip()
|
||
_DOCS_REALM = "OPC Docs"
|
||
|
||
|
||
def _is_docs_path(path: str) -> bool:
|
||
return (
|
||
path == "/openapi.json"
|
||
or path == "/docs"
|
||
or path.startswith("/docs/")
|
||
or path == "/redoc"
|
||
or path.startswith("/redoc/")
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def _docs_basic_auth(request: Request, call_next):
|
||
if _is_docs_path(request.url.path):
|
||
if not _DOCS_PASSWORD:
|
||
# 未配置密码:隐藏文档端点(不暴露存在)
|
||
return Response(status_code=404)
|
||
auth = request.headers.get("authorization", "")
|
||
if not auth.startswith("Basic "):
|
||
return Response(
|
||
status_code=401,
|
||
headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'},
|
||
)
|
||
try:
|
||
raw = base64.b64decode(auth[6:].strip()).decode("utf-8")
|
||
user, pwd = raw.split(":", 1)
|
||
except Exception: # noqa: BLE001
|
||
return Response(
|
||
status_code=401,
|
||
headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'},
|
||
)
|
||
if user != _DOCS_USERNAME or not secrets.compare_digest(pwd, _DOCS_PASSWORD):
|
||
return Response(
|
||
status_code=401,
|
||
headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'},
|
||
)
|
||
return await call_next(request)
|
||
|
||
|
||
app.include_router(auth_router.router)
|
||
app.include_router(agents_router.router)
|
||
app.include_router(templates_router.router)
|
||
app.include_router(bootstrap_router.router)
|
||
app.include_router(rbac_admin_router.router)
|
||
app.include_router(rbac_opc_router.router)
|
||
app.include_router(rbac_operator_router.router)
|
||
app.include_router(rbac_agents_router.router)
|
||
app.include_router(rbac_public_router.router)
|
||
app.include_router(rbac_org_router.router)
|
||
app.include_router(rbac_permissions_router.router)
|
||
app.include_router(rbac_portals_router.router)
|
||
app.include_router(rbac_hall_router.router)
|
||
app.include_router(rbac_service_market_router.router)
|
||
app.include_router(rbac_pages_router.router)
|
||
app.include_router(rbac_certifications_router.router)
|
||
app.include_router(rbac_training_router.router)
|
||
app.include_router(rbac_credit_router.router)
|
||
app.include_router(rbac_ecosystem_router.router)
|
||
app.include_router(rbac_enterprise_router.router)
|
||
app.include_router(rbac_ent_onboard_router.router)
|
||
app.include_router(rbac_teams_router.router)
|
||
app.include_router(rbac_desktop_updates_router.router)
|
||
app.include_router(rbac_desktop_updates_router.public_router)
|
||
app.include_router(rbac_hall_router.community_router)
|
||
app.include_router(rbac_moderation_router.router)
|
||
app.include_router(rbac_moderation_router.report_router)
|
||
app.include_router(rbac_hall_router.dm_router)
|
||
# compute_pricing 必须在 relay_router 之前注册:relay 有 /{path:path} catch-all,
|
||
# 会吞掉 /api/compute/* 导致全部 404。
|
||
app.include_router(compute_pricing_router.router)
|
||
app.include_router(compute_assets_router.router)
|
||
app.include_router(relay_router.router)
|
||
app.include_router(pay_router.router)
|
||
app.include_router(market_router.router)
|
||
app.include_router(market_router.admin_router)
|
||
app.include_router(incubator_router.router)
|
||
app.include_router(invite_router.router)
|
||
app.include_router(agent_gate_router.router)
|
||
app.include_router(agent_gate_router.internal_router)
|
||
app.include_router(im_router.router)
|
||
|
||
|
||
@app.get("/health", tags=["meta"], summary="健康检查")
|
||
async def health():
|
||
return {
|
||
"status": "ok",
|
||
"service": "pineagents-demo-server",
|
||
"auth_enabled": config.AUTH_ENABLED,
|
||
}
|
||
|
||
|
||
@app.get("/", tags=["meta"], include_in_schema=False)
|
||
async def root():
|
||
return {
|
||
"service": APP_NAME,
|
||
"docs": "/docs",
|
||
"health": "/health",
|
||
}
|